mirror of
https://github.com/anchore/syft.git
synced 2026-10-12 22:27:22 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69692ad901 |
-133
@@ -1,133 +0,0 @@
|
||||
tools:
|
||||
# we want to use a pinned version of binny to manage the toolchain (so binny manages itself!)
|
||||
- name: binny
|
||||
version:
|
||||
want: v0.9.0
|
||||
method: github-release
|
||||
with:
|
||||
repo: anchore/binny
|
||||
|
||||
# used to produce SBOMs during release
|
||||
- name: syft
|
||||
version:
|
||||
want: latest
|
||||
method: github-release
|
||||
with:
|
||||
repo: anchore/syft
|
||||
|
||||
# used to sign mac binaries at release
|
||||
- name: quill
|
||||
version:
|
||||
want: v0.5.1
|
||||
method: github-release
|
||||
with:
|
||||
repo: anchore/quill
|
||||
|
||||
# used for linting
|
||||
- name: golangci-lint
|
||||
version:
|
||||
want: v2.1.6
|
||||
method: github-release
|
||||
with:
|
||||
repo: golangci/golangci-lint
|
||||
|
||||
# used for showing the changelog at release
|
||||
- name: glow
|
||||
version:
|
||||
want: v2.1.0
|
||||
method: github-release
|
||||
with:
|
||||
repo: charmbracelet/glow
|
||||
|
||||
# used for signing the checksums file at release
|
||||
- name: cosign
|
||||
version:
|
||||
want: v2.5.0
|
||||
method: github-release
|
||||
with:
|
||||
repo: sigstore/cosign
|
||||
|
||||
# used in integration tests to verify JSON schemas
|
||||
- name: yajsv
|
||||
version:
|
||||
want: v1.4.1
|
||||
method: github-release
|
||||
with:
|
||||
repo: neilpa/yajsv
|
||||
|
||||
# used to release all artifacts
|
||||
- name: goreleaser
|
||||
version:
|
||||
want: v2.9.0
|
||||
method: github-release
|
||||
with:
|
||||
repo: goreleaser/goreleaser
|
||||
|
||||
# used for organizing imports during static analysis
|
||||
- name: gosimports
|
||||
version:
|
||||
want: v0.3.8
|
||||
method: github-release
|
||||
with:
|
||||
repo: rinchsan/gosimports
|
||||
|
||||
# used at release to generate the changelog
|
||||
- name: chronicle
|
||||
version:
|
||||
want: v0.8.0
|
||||
method: github-release
|
||||
with:
|
||||
repo: anchore/chronicle
|
||||
|
||||
# used during static analysis for license compliance
|
||||
- name: bouncer
|
||||
version:
|
||||
want: v0.4.0
|
||||
method: github-release
|
||||
with:
|
||||
repo: wagoodman/go-bouncer
|
||||
|
||||
# used for showing benchmark testing
|
||||
- name: benchstat
|
||||
version:
|
||||
want: latest
|
||||
method: go-proxy
|
||||
with:
|
||||
module: golang.org/x/perf
|
||||
allow-unresolved-version: true
|
||||
method: go-install
|
||||
with:
|
||||
entrypoint: cmd/benchstat
|
||||
module: golang.org/x/perf
|
||||
|
||||
# used for running all local and CI tasks
|
||||
- name: task
|
||||
version:
|
||||
want: v3.43.3
|
||||
method: github-release
|
||||
with:
|
||||
repo: go-task/task
|
||||
|
||||
# used for triggering a release
|
||||
- name: gh
|
||||
version:
|
||||
want: v2.72.0
|
||||
method: github-release
|
||||
with:
|
||||
repo: cli/cli
|
||||
|
||||
# used to upload test fixture cache
|
||||
- name: oras
|
||||
version:
|
||||
want: v1.2.3
|
||||
method: github-release
|
||||
with:
|
||||
repo: oras-project/oras
|
||||
|
||||
# used to upload test fixture cache
|
||||
- name: yq
|
||||
version:
|
||||
want: v4.45.4
|
||||
method: github-release
|
||||
with:
|
||||
repo: mikefarah/yq
|
||||
@@ -1,24 +1,13 @@
|
||||
permit:
|
||||
- BSD.*
|
||||
- CC0.*
|
||||
- MIT.*
|
||||
- Apache.*
|
||||
- MPL.*
|
||||
- ISC
|
||||
- WTFPL
|
||||
- Unlicense
|
||||
|
||||
ignore-packages:
|
||||
# packageurl-go is released under the MIT license located in the root of the repo at /mit.LICENSE
|
||||
- github.com/anchore/packageurl-go
|
||||
|
||||
# both of these dependencies are specified as Apache-2.0 in their respective GitHub READMEs
|
||||
- github.com/alibabacloud-go/cr-20160607/client
|
||||
- github.com/alibabacloud-go/tea-xml/service
|
||||
|
||||
# crypto/internal/boring is released under the openSSL license as a part of the Golang Standard Libary
|
||||
- crypto/internal/boring
|
||||
|
||||
# from: https://github.com/spdx/tools-golang/blob/main/LICENSE.code
|
||||
# The tools-golang source code is provided and may be used, at your option,
|
||||
# under either:
|
||||
@@ -31,32 +20,3 @@ ignore-packages:
|
||||
# All these files have been put into the public domain.
|
||||
# You can do whatever you want with these files.
|
||||
- github.com/xi2/xz
|
||||
|
||||
# from: https://gitlab.com/cznic/sqlite/-/blob/v1.15.4/LICENSE
|
||||
# This is a BSD-3-Clause license
|
||||
- modernc.org/libc
|
||||
- modernc.org/libc/errno
|
||||
- modernc.org/libc/fcntl
|
||||
- modernc.org/libc/fts
|
||||
- modernc.org/libc/grp
|
||||
- modernc.org/libc/langinfo
|
||||
- modernc.org/libc/limits
|
||||
- modernc.org/libc/netdb
|
||||
- modernc.org/libc/netinet/in
|
||||
- modernc.org/libc/poll
|
||||
- modernc.org/libc/pthread
|
||||
- modernc.org/libc/pwd
|
||||
- modernc.org/libc/signal
|
||||
- modernc.org/libc/stdio
|
||||
- modernc.org/libc/stdlib
|
||||
- modernc.org/libc/sys/socket
|
||||
- modernc.org/libc/sys/stat
|
||||
- modernc.org/libc/sys/types
|
||||
- modernc.org/libc/termios
|
||||
- modernc.org/libc/time
|
||||
- modernc.org/libc/unistd
|
||||
- modernc.org/libc/utime
|
||||
- modernc.org/libc/uuid/uuid
|
||||
- modernc.org/libc/wctype
|
||||
- modernc.org/mathutil
|
||||
- modernc.org/memory
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
enforce-v0: false
|
||||
title: ""
|
||||
|
||||
github:
|
||||
host: github.com
|
||||
include-issue-pr-authors: true
|
||||
include-issue-prs: true
|
||||
include-issues-not-planned: false
|
||||
include-prs: true
|
||||
include-issues: true
|
||||
include-unlabeled-issues: true
|
||||
include-unlabeled-prs: true
|
||||
issues-require-linked-prs: false
|
||||
consider-pr-merge-commits: true
|
||||
|
||||
exclude-labels:
|
||||
- duplicate
|
||||
- question
|
||||
- invalid
|
||||
- wontfix
|
||||
- wont-fix
|
||||
- release-ignore
|
||||
- changelog-ignore
|
||||
- ignore
|
||||
|
||||
changes:
|
||||
|
||||
- name: security-fixes
|
||||
title: Security Fixes
|
||||
semver-field: patch
|
||||
labels:
|
||||
- security
|
||||
- vulnerability
|
||||
|
||||
- name: added-feature
|
||||
title: Added Features
|
||||
semver-field: minor
|
||||
labels:
|
||||
- enhancement
|
||||
- feature
|
||||
- minor
|
||||
|
||||
- name: bug-fix
|
||||
title: Bug Fixes
|
||||
semver-field: patch
|
||||
labels:
|
||||
- bug
|
||||
- fix
|
||||
- bug-fix
|
||||
- patch
|
||||
|
||||
- name: breaking-feature
|
||||
title: Breaking Changes
|
||||
semver-field: major
|
||||
labels:
|
||||
- breaking
|
||||
- backwards-incompatible
|
||||
- breaking-change
|
||||
- breaking-feature
|
||||
- major
|
||||
- detected-breaking-change
|
||||
|
||||
- name: removed-feature
|
||||
title: Removed Features
|
||||
semver-field: major
|
||||
labels:
|
||||
- removed
|
||||
|
||||
- name: deprecated-feature
|
||||
title: Deprecated Features
|
||||
semver-field: minor
|
||||
labels:
|
||||
- deprecated
|
||||
|
||||
- name: unknown
|
||||
title: Additional Changes
|
||||
semver-field: ""
|
||||
labels: []
|
||||
@@ -11,7 +11,7 @@ assignees: ''
|
||||
|
||||
**What you expected to happen**:
|
||||
|
||||
**Steps to reproduce the issue**:
|
||||
**How to reproduce it (as minimally and precisely as possible)**:
|
||||
|
||||
**Anything else we need to know?**:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
contact_links:
|
||||
|
||||
- name: Join our Discourse community 💬
|
||||
# link to our community Discourse site
|
||||
url: https://anchore.com/discourse
|
||||
- name: Join the Slack community 💬
|
||||
# link to our community Slack registration page
|
||||
url: https://anchore.com/slack
|
||||
about: 'Come chat with us! Ask for help, join our software development efforts, or just give us feedback!'
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
name: "Bootstrap"
|
||||
|
||||
description: "Bootstrap all tools and dependencies"
|
||||
inputs:
|
||||
go-version:
|
||||
description: "Go version to install"
|
||||
required: true
|
||||
default: ">= 1.24"
|
||||
go-dependencies:
|
||||
description: "Download go dependencies"
|
||||
required: true
|
||||
default: "true"
|
||||
cache-key-prefix:
|
||||
description: "Prefix all cache keys with this value"
|
||||
required: true
|
||||
default: "181053ac82"
|
||||
download-test-fixture-cache:
|
||||
description: "Download test fixture cache from OCI and github actions"
|
||||
required: true
|
||||
default: "false"
|
||||
tools:
|
||||
description: "whether to install tools"
|
||||
default: "true"
|
||||
bootstrap-apt-packages:
|
||||
description: "Space delimited list of tools to install via apt"
|
||||
default: "libxml2-utils"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
# note: go mod and build is automatically cached on default with v4+
|
||||
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
if: inputs.go-version != ''
|
||||
with:
|
||||
go-version: ${{ inputs.go-version }}
|
||||
|
||||
- name: Restore tool cache
|
||||
if: inputs.tools == 'true'
|
||||
id: tool-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: ${{ github.workspace }}/.tool
|
||||
key: ${{ inputs.cache-key-prefix }}-${{ runner.os }}-tool-${{ hashFiles('.binny.yaml') }}
|
||||
|
||||
- name: Install project tools
|
||||
shell: bash
|
||||
if: inputs.tools == 'true'
|
||||
run: make tools
|
||||
|
||||
- name: Install go dependencies
|
||||
if: inputs.go-dependencies == 'true'
|
||||
shell: bash
|
||||
run: make ci-bootstrap-go
|
||||
|
||||
- name: Install apt packages
|
||||
if: inputs.bootstrap-apt-packages != ''
|
||||
shell: bash
|
||||
run: |
|
||||
DEBIAN_FRONTEND=noninteractive sudo apt update && sudo -E apt install -y ${{ inputs.bootstrap-apt-packages }}
|
||||
|
||||
- name: Restore ORAS cache from github actions
|
||||
if: inputs.download-test-fixture-cache == 'true'
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: ${{ github.workspace }}/.tmp/oras-cache
|
||||
key: ${{ inputs.cache-key-prefix }}-oras-cache
|
||||
|
||||
- name: Download test fixture cache
|
||||
if: inputs.download-test-fixture-cache == 'true'
|
||||
shell: bash
|
||||
run: make download-test-fixture-cache
|
||||
@@ -1,27 +0,0 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/.github/actions/bootstrap"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
@@ -1,25 +0,0 @@
|
||||
# Description
|
||||
|
||||
Please include a summary of the changes along with any relevant motivation and context,
|
||||
or link to an issue where this is explained.
|
||||
|
||||
<!-- If this completes an issue, please include: -->
|
||||
|
||||
- Fixes
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Delete any that are not relevant -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (please discuss with the team first; Syft is 1.0 software and we won't accept breaking changes without going to 2.0)
|
||||
- [ ] Documentation (updates the documentation)
|
||||
- [ ] Chore (improve the developer experience, fix a test flake, etc, without changing the visible behavior of Syft)
|
||||
- [ ] Performance (make Syft run faster or use less memory, without changing visible behavior much)
|
||||
|
||||
# Checklist:
|
||||
|
||||
- [ ] I have added unit tests that cover changed behavior
|
||||
- [ ] I have tested my code in common scenarios and confirmed there are no regressions
|
||||
- [ ] I have added comments to my code, particularly in hard-to-understand sections
|
||||
@@ -0,0 +1,3 @@
|
||||
dev-pki
|
||||
log
|
||||
signing-identity.txt
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
# grab utilities
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
. "$SCRIPT_DIR"/utils.sh
|
||||
|
||||
# cleanup any dev certs left behind
|
||||
. "$SCRIPT_DIR"/setup-dev.sh
|
||||
cleanup_signing
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
set -ue
|
||||
|
||||
set +xu
|
||||
if [ -z "$AC_USERNAME" ]; then
|
||||
exit_with_error "AC_USERNAME not set"
|
||||
fi
|
||||
|
||||
if [ -z "$AC_PASSWORD" ]; then
|
||||
exit_with_error "AC_PASSWORD not set"
|
||||
fi
|
||||
set -u
|
||||
|
||||
# repackage [archive-path]
|
||||
#
|
||||
# returns an archive compatible for Apple's notarization process, repackaging the input archive as needed
|
||||
#
|
||||
repackage() {
|
||||
archive=$1
|
||||
|
||||
case "$archive" in
|
||||
*.tar.gz)
|
||||
new_archive=${archive%.tar.gz}.zip
|
||||
(
|
||||
tmp_dir=$(mktemp -d)
|
||||
cd "$tmp_dir"
|
||||
# redirect stdout to stderr to preserve the return value
|
||||
tar xzf "$archive" && zip "$new_archive" ./* 1>&2
|
||||
rm -rf "$tmp_dir"
|
||||
)
|
||||
echo "$new_archive"
|
||||
;;
|
||||
*.zip)
|
||||
echo "$archive"
|
||||
;;
|
||||
*) return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# notarize [archive-path]
|
||||
#
|
||||
notarize() {
|
||||
archive_path=$1
|
||||
|
||||
title "notarizing binaries found in the release archive"
|
||||
|
||||
payload_archive_path=$(repackage "$archive_path")
|
||||
if [ "$?" != "0" ]; then
|
||||
exit_with_error "cannot prepare payload for notarization: $archive_path"
|
||||
fi
|
||||
|
||||
if [ ! -f "$payload_archive_path" ]; then
|
||||
exit_with_error "cannot find payload for notarization: $payload_archive_path"
|
||||
fi
|
||||
|
||||
# install gon
|
||||
which gon || (brew tap mitchellh/gon && brew install mitchellh/gon/gon)
|
||||
|
||||
# create config (note: json via stdin with gon is broken, can only use HCL from file)
|
||||
tmp_file=$(mktemp).hcl
|
||||
|
||||
cat <<EOF > "$tmp_file"
|
||||
notarize {
|
||||
path = "$payload_archive_path"
|
||||
bundle_id = "com.anchore.toolbox.syft"
|
||||
}
|
||||
|
||||
apple_id {
|
||||
username = "$AC_USERNAME"
|
||||
password = "@env:AC_PASSWORD"
|
||||
}
|
||||
EOF
|
||||
|
||||
gon -log-level info "$tmp_file"
|
||||
|
||||
result="$?"
|
||||
|
||||
rm "$tmp_file"
|
||||
|
||||
if [ "$result" -ne "0" ]; then
|
||||
exit_with_error "notarization failed"
|
||||
fi
|
||||
}
|
||||
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
NAME=syft-dev
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
DIR=$SCRIPT_DIR/dev-pki
|
||||
FILE_PREFIX=$DIR/$NAME
|
||||
IDENTITY=${NAME}-id-415d8c69793
|
||||
|
||||
## OpenSSL material
|
||||
|
||||
KEY_PASSWORD="letthedevin"
|
||||
P12_PASSWORD="popeofnope"
|
||||
|
||||
KEY_FILE=$FILE_PREFIX-key.pem
|
||||
CSR_FILE=$FILE_PREFIX-csr.pem
|
||||
CERT_FILE=$FILE_PREFIX-cert.pem
|
||||
EXT_FILE=$FILE_PREFIX-ext.cnf
|
||||
P12_FILE=$FILE_PREFIX.p12
|
||||
|
||||
EXT_SECTION=codesign_reqext
|
||||
|
||||
## Keychain material
|
||||
|
||||
KEYCHAIN_NAME=$NAME
|
||||
KEYCHAIN_PATH=$HOME/Library/Keychains/$KEYCHAIN_NAME-db
|
||||
KEYCHAIN_PASSWORD="topsykretts"
|
||||
|
||||
# setup_signing
|
||||
#
|
||||
# preps the MAC_SIGNING_IDENTITY env var for use in the signing process, using ephemeral developer certificate material
|
||||
#
|
||||
function setup_signing() {
|
||||
# check to see if this has already been done... if so, bail!
|
||||
set +ue
|
||||
if security find-identity -p codesigning "$KEYCHAIN_PATH" | grep $IDENTITY ; then
|
||||
export MAC_SIGNING_IDENTITY=$IDENTITY
|
||||
commentary "skipping creating dev certificate material (already exists)"
|
||||
commentary "setting MAC_SIGNING_IDENTITY=${IDENTITY}"
|
||||
return 0
|
||||
fi
|
||||
set -ue
|
||||
|
||||
title "setting up developer certificate material"
|
||||
|
||||
mkdir -p "${DIR}"
|
||||
|
||||
# configure the openssl extensions
|
||||
cat << EOF > $EXT_FILE
|
||||
[ req ]
|
||||
default_bits = 2048 # RSA key size
|
||||
encrypt_key = yes # Protect private key
|
||||
default_md = sha256 # MD to use
|
||||
utf8 = yes # Input is UTF-8
|
||||
string_mask = utf8only # Emit UTF-8 strings
|
||||
prompt = yes # Prompt for DN
|
||||
distinguished_name = codesign_dn # DN template
|
||||
req_extensions = $EXT_SECTION # Desired extensions
|
||||
|
||||
[ codesign_dn ]
|
||||
commonName = $IDENTITY
|
||||
commonName_max = 64
|
||||
|
||||
[ $EXT_SECTION ]
|
||||
keyUsage = critical,digitalSignature
|
||||
extendedKeyUsage = critical,codeSigning
|
||||
subjectKeyIdentifier = hash
|
||||
EOF
|
||||
|
||||
title "create the private key"
|
||||
openssl genrsa \
|
||||
-des3 \
|
||||
-out "$KEY_FILE" \
|
||||
-passout "pass:$KEY_PASSWORD" \
|
||||
2048
|
||||
|
||||
title "create the csr"
|
||||
openssl req \
|
||||
-new \
|
||||
-key "$KEY_FILE" \
|
||||
-out "$CSR_FILE" \
|
||||
-passin "pass:$KEY_PASSWORD" \
|
||||
-config "$EXT_FILE" \
|
||||
-subj "/CN=$IDENTITY"
|
||||
|
||||
commentary "verify the csr: we should see X509 v3 extensions for codesigning in the CSR"
|
||||
openssl req -in "$CSR_FILE" -noout -text | grep -A1 "X509v3" || exit_with_error "could not find x509 extensions in CSR"
|
||||
|
||||
title "create the certificate"
|
||||
# note: Extensions in certificates are not transferred to certificate requests and vice versa. This means that
|
||||
# just because the CSR has x509 v3 extensions doesn't mean that you'll see these extensions in the cert output.
|
||||
# To prove this do:
|
||||
# openssl x509 -text -noout -in server.crt | grep -A10 "X509v3 extensions:"
|
||||
# ... and you will see no output (if -extensions is not used). (see https://www.openssl.org/docs/man1.1.0/man1/x509.html#BUGS)
|
||||
# To get the extensions, use "-extensions codesign_reqext" when creating the cert. The codesign_reqext value matches
|
||||
# the section name in the ext file used in CSR / cert creation (-extfile and -config).
|
||||
openssl x509 \
|
||||
-req \
|
||||
-days 10000 \
|
||||
-in "$CSR_FILE" \
|
||||
-signkey "$KEY_FILE" \
|
||||
-out "$CERT_FILE" \
|
||||
-extfile "$EXT_FILE" \
|
||||
-passin "pass:$KEY_PASSWORD" \
|
||||
-extensions $EXT_SECTION
|
||||
|
||||
commentary "verify the certificate: we should see our extensions"
|
||||
openssl x509 -text -noout -in $CERT_FILE | grep -A1 'X509v3' || exit_with_error "could not find x509 extensions in certificate"
|
||||
|
||||
title "export cert and private key to .p12 file"
|
||||
# note: this step may be entirely optional, however, I found it useful to follow the prod path which goes the route of using a p12
|
||||
openssl pkcs12 \
|
||||
-export \
|
||||
-out "$P12_FILE" \
|
||||
-inkey "$KEY_FILE" \
|
||||
-in "$CERT_FILE" \
|
||||
-passin "pass:$KEY_PASSWORD" \
|
||||
-passout "pass:$P12_PASSWORD"
|
||||
|
||||
|
||||
title "create the dev keychain"
|
||||
|
||||
# delete the keychain if it already exists
|
||||
if [ -f "$(KEYCHAIN_PATH)" ]; then
|
||||
security delete-keychain "$KEYCHAIN_NAME" &> /dev/null
|
||||
fi
|
||||
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
|
||||
|
||||
set +e
|
||||
if ! security verify-cert -k "$KEYCHAIN_PATH" -c "$CERT_FILE" &> /dev/null; then
|
||||
set -e
|
||||
title "import the cert into the dev keychain if it is not already trusted by the system"
|
||||
|
||||
security import "$P12_FILE" -P $P12_PASSWORD -f pkcs12 -k "$KEYCHAIN_PATH" -T /usr/bin/codesign
|
||||
|
||||
# note: set the partition list for this certificate's private key to include "apple-tool:" and "apple:" allows the codesign command to access this keychain item without an interactive user prompt.
|
||||
security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
# note: add-trusted-cert requires user interaction
|
||||
commentary "adding the developer certificate as a trusted certificate... (requires user interaction)"
|
||||
security add-trusted-cert -d -r trustRoot -k "$KEYCHAIN_PATH" "$CERT_FILE"
|
||||
else
|
||||
set -e
|
||||
commentary "...dev cert has already been imported onto the dev keychain"
|
||||
fi
|
||||
|
||||
# remove any generated cert material since the keychain now has all of this material loaded
|
||||
rm -rf "${DIR}"
|
||||
|
||||
commentary "make certain there are identities that can be used for code signing"
|
||||
security find-identity -p codesigning "$KEYCHAIN_PATH" | grep -C 30 "$IDENTITY" || exit_with_error "could not find identity that can be used with codesign"
|
||||
|
||||
title "add the dev keychain to the search path for codesign"
|
||||
add_keychain $KEYCHAIN_NAME
|
||||
|
||||
commentary "verify the keychain actually shows up"
|
||||
security list-keychains | grep "$KEYCHAIN_NAME" || exit_with_error "could not find new keychain"
|
||||
|
||||
export MAC_SIGNING_IDENTITY=$IDENTITY
|
||||
commentary "setting MAC_SIGNING_IDENTITY=${IDENTITY}"
|
||||
|
||||
}
|
||||
|
||||
function cleanup_signing() {
|
||||
title "delete the dev keychain and all certificate material"
|
||||
set -xue
|
||||
security delete-keychain "$KEYCHAIN_NAME"
|
||||
rm -f "$KEYCHAIN_PATH"
|
||||
rm -rf "${DIR}"
|
||||
}
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
assert_in_ci
|
||||
|
||||
set +xu
|
||||
if [ -z "$APPLE_DEVELOPER_ID_CERT" ]; then
|
||||
exit_with_error "APPLE_DEVELOPER_ID_CERT not set"
|
||||
fi
|
||||
|
||||
if [ -z "$APPLE_DEVELOPER_ID_CERT_PASS" ]; then
|
||||
exit_with_error "APPLE_DEVELOPER_ID_CERT_PASS not set"
|
||||
fi
|
||||
|
||||
if [ -z "$DOCKER_USERNAME" ]; then
|
||||
exit_with_error "DOCKER_USERNAME not set"
|
||||
fi
|
||||
|
||||
if [ -z "$DOCKER_PASSWORD" ]; then
|
||||
exit_with_error "DOCKER_PASSWORD not set"
|
||||
fi
|
||||
set -u
|
||||
|
||||
# setup_signing
|
||||
#
|
||||
# preps the MAC_SIGNING_IDENTITY env var for use in the signing process, using production certificate material
|
||||
#
|
||||
setup_signing() {
|
||||
title "setting up production certificate material"
|
||||
|
||||
# Write signing certificate to disk from environment variable.
|
||||
cert_file="$HOME/developer_id_certificate.p12"
|
||||
echo -n "$APPLE_DEVELOPER_ID_CERT" | base64 --decode > "$cert_file"
|
||||
|
||||
# In order to have all keychain interactions avoid an interactive user prompt, we need to control the password for the keychain in question, which means we need to create a new keychain into which we'll import the signing certificate and from which we'll later access this certificate during code signing.
|
||||
ephemeral_keychain="ci-ephemeral-keychain"
|
||||
ephemeral_keychain_password="$(openssl rand -base64 100)"
|
||||
security create-keychain -p "${ephemeral_keychain_password}" "${ephemeral_keychain}"
|
||||
|
||||
# Import signing certificate into the keychain. (This is a pre-requisite for gon, which is invoked via goreleaser.)
|
||||
ephemeral_keychain_full_path="$HOME/Library/Keychains/${ephemeral_keychain}-db"
|
||||
security import "${cert_file}" -k "${ephemeral_keychain_full_path}" -P "${APPLE_DEVELOPER_ID_CERT_PASS}" -T "$(command -v codesign)"
|
||||
|
||||
# Setting the partition list for this certificate's private key to include "apple-tool:" and "apple:" allows the codesign command to access this keychain item without an interactive user prompt. (codesign is invoked by gon.)
|
||||
security set-key-partition-list -S "apple-tool:,apple:" -s -k "${ephemeral_keychain_password}" "${ephemeral_keychain_full_path}"
|
||||
|
||||
# Make this new keychain the user's default keychain, so that codesign will be able to find this certificate when we specify it during signing.
|
||||
security default-keychain -d "user" -s "${ephemeral_keychain_full_path}"
|
||||
|
||||
# TODO: extract this from the certificate material itself
|
||||
export MAC_SIGNING_IDENTITY="Developer ID Application: ANCHORE, INC. (9MJHKYX5AT)"
|
||||
commentary "setting MAC_SIGNING_IDENTITY=${MAC_SIGNING_IDENTITY}"
|
||||
|
||||
commentary "log into docker -- required for publishing (since the default keychain has now been replaced)"
|
||||
echo "${DOCKER_PASSWORD}" | docker login docker.io -u "${DOCKER_USERNAME}" --password-stdin
|
||||
}
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
IS_SNAPSHOT="$1"
|
||||
|
||||
## grab utilities
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
. "$SCRIPT_DIR"/utils.sh
|
||||
|
||||
main() {
|
||||
|
||||
case "$IS_SNAPSHOT" in
|
||||
|
||||
"1" | "true" | "yes")
|
||||
commentary "assuming development setup..."
|
||||
. "$SCRIPT_DIR"/setup-dev.sh
|
||||
;;
|
||||
|
||||
"0" | "false" | "no")
|
||||
commentary "assuming production setup..."
|
||||
. "$SCRIPT_DIR"/setup-prod.sh
|
||||
;;
|
||||
|
||||
*)
|
||||
exit_with_error "could not determine if this was a production build (isSnapshot='$IS_SNAPSHOT')"
|
||||
;;
|
||||
esac
|
||||
|
||||
# load up all signing material into a keychain (note: this should set the MAC_SIGNING_IDENTITY env var)
|
||||
setup_signing
|
||||
|
||||
# write out identity to a file
|
||||
echo -n "$MAC_SIGNING_IDENTITY" > "$SCRIPT_DIR/$SIGNING_IDENTITY_FILENAME"
|
||||
}
|
||||
|
||||
set +u
|
||||
if [ -z "$SCRIPT" ]
|
||||
then
|
||||
set -u
|
||||
# log all output
|
||||
mkdir -p "$SCRIPT_DIR/log"
|
||||
/usr/bin/script "$SCRIPT_DIR/log/setup.txt" /bin/bash -c "$0 $*"
|
||||
exit $?
|
||||
elif [ -n "$SKIP_SIGNING" ]; then
|
||||
commentary "skipping signing setup..."
|
||||
else
|
||||
set -u
|
||||
main
|
||||
fi
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
ARCHIVE_PATH="$1"
|
||||
IS_SNAPSHOT="$2"
|
||||
|
||||
## grab utilities
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
. "$SCRIPT_DIR"/utils.sh
|
||||
|
||||
|
||||
# sign_binary [binary-path] [signing-identity]
|
||||
#
|
||||
# signs a single binary with cosign
|
||||
#
|
||||
sign_binary() {
|
||||
exe_path=$1
|
||||
identity=$2
|
||||
|
||||
if [ -x "$exe_path" ] && file -b "$exe_path" | grep -q "Mach-O"
|
||||
then
|
||||
echo "signing $exe_path ..."
|
||||
else
|
||||
echo "skip signing $exe_path ..."
|
||||
return 0
|
||||
fi
|
||||
|
||||
codesign \
|
||||
-s "$identity" \
|
||||
-f \
|
||||
--verbose=4 \
|
||||
--timestamp \
|
||||
--options runtime \
|
||||
$exe_path
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
exit_with_error "signing failed"
|
||||
fi
|
||||
|
||||
codesign --verify "$exe_path" --verbose=4
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
exit_with_error "signing verification failed"
|
||||
fi
|
||||
}
|
||||
|
||||
# sign_binaries_in_archive [archive-abs-path] [signing-identity]
|
||||
#
|
||||
# signs all binaries within an archive (there must be at least one)
|
||||
#
|
||||
sign_binaries_in_archive() {
|
||||
archive_abs_path=$1
|
||||
identity=$2
|
||||
|
||||
scratch_path=$(mktemp -d)
|
||||
trap "rm -rf -- $scratch_path" EXIT
|
||||
|
||||
title "getting contents from the release archive: $archive_abs_path"
|
||||
tar -C "$scratch_path" -xvf "$archive_abs_path"
|
||||
|
||||
# invalidate the current archive, we only want an asset with signed binaries from this point forward
|
||||
rm "$archive_abs_path"
|
||||
|
||||
title "signing binaries found in the release archive"
|
||||
|
||||
discovered_binaries=0
|
||||
tmp_pipe=$(mktemp -ut pipe.XXX)
|
||||
mkfifo "$tmp_pipe"
|
||||
|
||||
find "$scratch_path" -perm +111 -type f > "$tmp_pipe" &
|
||||
|
||||
while IFS= read -r binary; do
|
||||
sign_binary "$binary" "$identity"
|
||||
((discovered_binaries++))
|
||||
done < "$tmp_pipe"
|
||||
|
||||
rm "$tmp_pipe"
|
||||
|
||||
if [ "$discovered_binaries" = "0" ]; then
|
||||
exit_with_error "found no binaries to sign"
|
||||
fi
|
||||
|
||||
title "recreating the release archive: $archive_abs_path"
|
||||
(cd "$scratch_path" && tar -czvf "$archive_abs_path" .)
|
||||
}
|
||||
|
||||
|
||||
main() {
|
||||
archive_abs_path=$(realpath "$ARCHIVE_PATH")
|
||||
|
||||
if [ ! -f "$archive_abs_path" ]; then
|
||||
echo "archive does not exist: $archive_abs_path"
|
||||
fi
|
||||
|
||||
case "$IS_SNAPSHOT" in
|
||||
|
||||
"1" | "true" | "yes")
|
||||
commentary "disabling notarization..."
|
||||
perform_notarization=false
|
||||
;;
|
||||
|
||||
"0" | "false" | "no")
|
||||
commentary "enabling notarization..."
|
||||
. "$SCRIPT_DIR"/notarize.sh
|
||||
perform_notarization=true
|
||||
;;
|
||||
|
||||
*)
|
||||
exit_with_error "could not determine if this was a production build (isSnapshot='$IS_SNAPSHOT')"
|
||||
;;
|
||||
esac
|
||||
|
||||
# grab the signing identity from the local temp file (setup by setup.sh)
|
||||
MAC_SIGNING_IDENTITY=$(cat "$SCRIPT_DIR/$SIGNING_IDENTITY_FILENAME")
|
||||
|
||||
# sign all of the binaries in the archive and recreate the input archive with the signed binaries
|
||||
sign_binaries_in_archive "$archive_abs_path" "$MAC_SIGNING_IDENTITY"
|
||||
|
||||
# send all of the binaries off to apple to bless
|
||||
if $perform_notarization ; then
|
||||
notarize "$archive_abs_path"
|
||||
else
|
||||
commentary "skipping notarization..."
|
||||
fi
|
||||
}
|
||||
|
||||
set +u
|
||||
if [ -z "$SCRIPT" ]
|
||||
then
|
||||
set -u
|
||||
# log all output
|
||||
mkdir -p "$SCRIPT_DIR/log"
|
||||
/usr/bin/script "$SCRIPT_DIR/log/signing-$(basename $ARCHIVE_PATH).txt" /bin/bash -c "$0 $*"
|
||||
exit $?
|
||||
elif [ -n "$SKIP_SIGNING" ]; then
|
||||
commentary "skipping signing..."
|
||||
else
|
||||
set -u
|
||||
main
|
||||
fi
|
||||
@@ -0,0 +1,78 @@
|
||||
SIGNING_IDENTITY_FILENAME=signing-identity.txt
|
||||
|
||||
## terminal goodies
|
||||
PURPLE='\033[0;35m'
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
BOLD=$(tput -T linux bold)
|
||||
RESET='\033[0m'
|
||||
|
||||
function success() {
|
||||
echo -e "\n${GREEN}${BOLD}$@${RESET}"
|
||||
}
|
||||
|
||||
function title() {
|
||||
success "Task: $@"
|
||||
}
|
||||
|
||||
function commentary() {
|
||||
echo -e "\n${PURPLE}# $@${RESET}"
|
||||
}
|
||||
|
||||
function error() {
|
||||
echo -e "${RED}${BOLD}error: $@${RESET}"
|
||||
}
|
||||
|
||||
function exit_with_error() {
|
||||
error $@
|
||||
exit 1
|
||||
}
|
||||
|
||||
function exit_with_message() {
|
||||
success $@
|
||||
exit 0
|
||||
}
|
||||
|
||||
function realpath {
|
||||
echo "$(cd $(dirname $1); pwd)/$(basename $1)";
|
||||
}
|
||||
|
||||
|
||||
# this function adds all of the existing keychains plus the new one which is the same as going to Keychain Access
|
||||
# and selecting "Add Keychain" to make the keychain visible under "Custom Keychains". This is done with
|
||||
# "security list-keychains -s" for some reason. The downside is that this sets the search path, not appends
|
||||
# to it, so you will loose existing keychains in the search path... which is truly terrible.
|
||||
function add_keychain() {
|
||||
keychains=$(security list-keychains -d user)
|
||||
keychainNames=();
|
||||
for keychain in $keychains
|
||||
do
|
||||
basename=$(basename "$keychain")
|
||||
keychainName=${basename::${#basename}-4}
|
||||
keychainNames+=("$keychainName")
|
||||
done
|
||||
|
||||
echo "existing user keychains: ${keychainNames[@]}"
|
||||
|
||||
security -v list-keychains -s "${keychainNames[@]}" "$1"
|
||||
}
|
||||
|
||||
function exit_not_ci() {
|
||||
printf "WARNING! It looks like this isn't the CI environment. This script modifies the macOS Keychain setup in ways you probably wouldn't want for your own machine. It also requires an Apple Developer ID Certificate that you shouldn't have outside of the CI environment.\n\nExiting early to make sure nothing bad happens.\n"
|
||||
exit 1
|
||||
}
|
||||
|
||||
CI_HOME="/Users/runner"
|
||||
|
||||
function assert_in_ci() {
|
||||
|
||||
if [[ "${HOME}" != "${CI_HOME}" ]]; then
|
||||
exit_not_ci
|
||||
fi
|
||||
|
||||
set +u
|
||||
if [ -z "${GITHUB_ACTIONS}" ]; then
|
||||
exit_not_ci
|
||||
fi
|
||||
set -u
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# current limit for fixture size
|
||||
size=1000
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <directory>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
directory="$1"
|
||||
|
||||
# Remove trailing slash using parameter expansion
|
||||
directory="${directory%/}"
|
||||
|
||||
if [ ! -d "$directory" ]; then
|
||||
echo "Directory not found: $directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
found_large_files=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if [ $(wc -c < "$file") -gt $size ]; then
|
||||
echo "File $file is greater than ${size} bytes."
|
||||
found_large_files=1
|
||||
fi
|
||||
done < <(find "$directory" -type f -print0)
|
||||
|
||||
if [ "$found_large_files" -eq 1 ]; then
|
||||
echo "Script failed: Some files are greater than ${size} bytes."
|
||||
exit 1
|
||||
else
|
||||
echo "All files in $directory and its subdirectories are ${size} bytes or smaller. Check passed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
red=$(tput setaf 1)
|
||||
bold=$(tput bold)
|
||||
normal=$(tput sgr0)
|
||||
|
||||
# assert we are running in CI (or die!)
|
||||
if [[ -z "$CI" ]]; then
|
||||
echo "${bold}${red}This step should ONLY be run in CI. Exiting...${normal}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import sys
|
||||
import shlex
|
||||
|
||||
|
||||
class bcolors:
|
||||
HEADER = '\033[95m'
|
||||
OKBLUE = '\033[94m'
|
||||
OKCYAN = '\033[96m'
|
||||
OKGREEN = '\033[92m'
|
||||
WARNING = '\033[93m'
|
||||
FAIL = '\033[91m'
|
||||
ENDC = '\033[0m'
|
||||
BOLD = '\033[1m'
|
||||
UNDERLINE = '\033[4m'
|
||||
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: coverage.py [threshold] [go-coverage-report]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
threshold = float(sys.argv[1])
|
||||
report = sys.argv[2]
|
||||
|
||||
|
||||
args = shlex.split(f"go tool cover -func {report}")
|
||||
p = subprocess.run(args, capture_output=True, text=True)
|
||||
|
||||
percent_coverage = float(p.stdout.splitlines()[-1].split()[-1].replace("%", ""))
|
||||
print(f"{bcolors.BOLD}Coverage: {percent_coverage}%{bcolors.ENDC}")
|
||||
|
||||
if percent_coverage < threshold:
|
||||
print(f"{bcolors.BOLD}{bcolors.FAIL}Coverage below threshold of {threshold}%{bcolors.ENDC}")
|
||||
sys.exit(1)
|
||||
@@ -1,137 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import glob
|
||||
import sys
|
||||
import json
|
||||
import hashlib
|
||||
|
||||
|
||||
IGNORED_PREFIXES = []
|
||||
|
||||
|
||||
def find_fingerprints_and_check_dirs(base_dir):
|
||||
all_fingerprints = set(glob.glob(os.path.join(base_dir, '**', 'test*', '**', '*.fingerprint'), recursive=True))
|
||||
|
||||
all_fingerprints = {os.path.relpath(fp) for fp in all_fingerprints
|
||||
if not any(fp.startswith(prefix) for prefix in IGNORED_PREFIXES)}
|
||||
|
||||
if not all_fingerprints:
|
||||
show("No .fingerprint files or cache directories found.")
|
||||
exit(1)
|
||||
|
||||
missing_content = []
|
||||
valid_paths = set()
|
||||
fingerprint_contents = []
|
||||
|
||||
for fingerprint in all_fingerprints:
|
||||
path = fingerprint.replace('.fingerprint', '')
|
||||
|
||||
if not os.path.exists(path):
|
||||
missing_content.append(path)
|
||||
continue
|
||||
|
||||
if not os.path.isdir(path):
|
||||
valid_paths.add(path)
|
||||
continue
|
||||
|
||||
if os.listdir(path):
|
||||
valid_paths.add(path)
|
||||
else:
|
||||
missing_content.append(path)
|
||||
|
||||
with open(fingerprint, 'r') as f:
|
||||
content = f.read().strip()
|
||||
fingerprint_contents.append((fingerprint, content))
|
||||
|
||||
return sorted(valid_paths), missing_content, fingerprint_contents
|
||||
|
||||
|
||||
def parse_fingerprint_contents(fingerprint_content):
|
||||
input_map = {}
|
||||
for line in fingerprint_content.splitlines():
|
||||
digest, path = line.split()
|
||||
input_map[path] = digest
|
||||
return input_map
|
||||
|
||||
|
||||
def calculate_sha256(fingerprint_contents):
|
||||
sorted_fingerprint_contents = sorted(fingerprint_contents, key=lambda x: x[0])
|
||||
|
||||
concatenated_contents = ''.join(content for _, content in sorted_fingerprint_contents)
|
||||
|
||||
sha256_hash = hashlib.sha256(concatenated_contents.encode()).hexdigest()
|
||||
|
||||
return sha256_hash
|
||||
|
||||
|
||||
def calculate_file_sha256(file_path):
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, 'rb') as f:
|
||||
for byte_block in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
def show(*s: str):
|
||||
print(*s, file=sys.stderr)
|
||||
|
||||
|
||||
def main(file_path: str | None):
|
||||
base_dir = '.'
|
||||
valid_paths, missing_content, fingerprint_contents = find_fingerprints_and_check_dirs(base_dir)
|
||||
|
||||
if missing_content:
|
||||
show("The following paths are missing or have no content, but have corresponding .fingerprint files:")
|
||||
for path in sorted(missing_content):
|
||||
show(f"- {path}")
|
||||
# when adding new cache directories there is a time where it is not possible to have this directory without
|
||||
# running the tests first... but this step is a prerequisite for running the tests. We should not block on this.
|
||||
# show("Please ensure these paths exist and have content if they are directories.")
|
||||
# exit(1)
|
||||
|
||||
sha256_hash = calculate_sha256(fingerprint_contents)
|
||||
|
||||
paths_with_digests = []
|
||||
for path in sorted(valid_paths):
|
||||
fingerprint_file = f"{path}.fingerprint"
|
||||
try:
|
||||
if os.path.exists(fingerprint_file):
|
||||
file_digest = calculate_file_sha256(fingerprint_file)
|
||||
|
||||
# Parse the fingerprint file to get the digest/path tuples
|
||||
with open(fingerprint_file, 'r') as f:
|
||||
fingerprint_content = f.read().strip()
|
||||
input_map = parse_fingerprint_contents(fingerprint_content)
|
||||
|
||||
paths_with_digests.append({
|
||||
"path": path,
|
||||
"digest": file_digest,
|
||||
"input": input_map
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
show(f"Error processing {fingerprint_file}: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
output = {
|
||||
"digest": sha256_hash,
|
||||
"paths": paths_with_digests
|
||||
}
|
||||
|
||||
content = json.dumps(output, indent=2, sort_keys=True)
|
||||
|
||||
if file_path:
|
||||
with open(file_path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
print(content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
file_path = None
|
||||
if len(sys.argv) > 1:
|
||||
file_path = sys.argv[1]
|
||||
main(file_path)
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import hashlib
|
||||
|
||||
BOLD = '\033[1m'
|
||||
YELLOW = '\033[0;33m'
|
||||
RESET = '\033[0m'
|
||||
|
||||
|
||||
def print_message(message):
|
||||
print(f"{YELLOW}{message}{RESET}")
|
||||
|
||||
|
||||
def sha256sum(filepath):
|
||||
h = hashlib.sha256()
|
||||
with open(filepath, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def is_git_tracked_or_untracked(directory):
|
||||
"""Returns a sorted list of files in the directory that are tracked or not ignored by Git."""
|
||||
result = subprocess.run(
|
||||
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
||||
cwd=directory,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
return sorted(result.stdout.strip().splitlines())
|
||||
|
||||
|
||||
def find_test_fixture_dirs_with_images(base_dir):
|
||||
"""Find directories that contain 'test-fixtures' and at least one 'image-*' directory."""
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
if 'test-fixtures' in root:
|
||||
image_dirs = [d for d in dirs if d.startswith('image-')]
|
||||
if image_dirs:
|
||||
yield os.path.realpath(root)
|
||||
|
||||
|
||||
def generate_fingerprints():
|
||||
print_message("creating fingerprint files for docker fixtures...")
|
||||
|
||||
for test_fixture_dir in find_test_fixture_dirs_with_images('.'):
|
||||
cache_fingerprint_path = os.path.join(test_fixture_dir, 'cache.fingerprint')
|
||||
|
||||
with open(cache_fingerprint_path, 'w') as fingerprint_file:
|
||||
for image_dir in find_image_dirs(test_fixture_dir):
|
||||
for file in is_git_tracked_or_untracked(image_dir):
|
||||
file_path = os.path.join(image_dir, file)
|
||||
checksum = sha256sum(file_path)
|
||||
path_from_fixture_dir = os.path.relpath(file_path, test_fixture_dir)
|
||||
fingerprint_file.write(f"{checksum} {path_from_fixture_dir}\n")
|
||||
|
||||
|
||||
def find_image_dirs(test_fixture_dir):
|
||||
"""Find all 'image-*' directories inside a given test-fixture directory."""
|
||||
result = []
|
||||
for root, dirs, files in os.walk(test_fixture_dir):
|
||||
for dir_name in dirs:
|
||||
if dir_name.startswith('image-'):
|
||||
result.append(os.path.join(root, dir_name))
|
||||
return sorted(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_fingerprints()
|
||||
@@ -4,18 +4,19 @@ set -eu
|
||||
ORIGINAL_STATE_DIR=$(mktemp -d "TEMP-original-state-XXXXXXXXX")
|
||||
TIDY_STATE_DIR=$(mktemp -d "TEMP-tidy-state-XXXXXXXXX")
|
||||
|
||||
trap "cp -p ${ORIGINAL_STATE_DIR}/* ./ && git update-index -q --refresh && rm -fR ${ORIGINAL_STATE_DIR} ${TIDY_STATE_DIR}" EXIT
|
||||
trap "cp -v ${ORIGINAL_STATE_DIR}/* ./ && rm -fR ${ORIGINAL_STATE_DIR} ${TIDY_STATE_DIR}" EXIT
|
||||
|
||||
# capturing original state of files...
|
||||
cp go.mod go.sum "${ORIGINAL_STATE_DIR}"
|
||||
echo "Capturing original state of files..."
|
||||
cp -v go.mod go.sum "${ORIGINAL_STATE_DIR}"
|
||||
|
||||
# capturing state of go.mod and go.sum after running go mod tidy...
|
||||
echo "Capturing state of go.mod and go.sum after running go mod tidy..."
|
||||
go mod tidy
|
||||
cp go.mod go.sum "${TIDY_STATE_DIR}"
|
||||
cp -v go.mod go.sum "${TIDY_STATE_DIR}"
|
||||
echo ""
|
||||
|
||||
set +e
|
||||
|
||||
# detect difference between the git HEAD state and the go mod tidy state
|
||||
# Detect difference between the git HEAD state and the go mod tidy state
|
||||
DIFF_MOD=$(diff -u "${ORIGINAL_STATE_DIR}/go.mod" "${TIDY_STATE_DIR}/go.mod")
|
||||
DIFF_SUM=$(diff -u "${ORIGINAL_STATE_DIR}/go.sum" "${TIDY_STATE_DIR}/go.sum")
|
||||
|
||||
|
||||
Executable
+398
@@ -0,0 +1,398 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
# Code generated by godownloader on 2019-12-25T12:47:14Z. DO NOT EDIT.
|
||||
#
|
||||
|
||||
usage() {
|
||||
this=$1
|
||||
cat <<EOF
|
||||
$this: download go binaries for goreleaser/goreleaser
|
||||
|
||||
Usage: $this [-b] bindir [-d] [tag]
|
||||
-b sets bindir or installation directory, Defaults to ./bin
|
||||
-d turns on debug logging
|
||||
[tag] is a tag from
|
||||
https://github.com/goreleaser/goreleaser/releases
|
||||
If tag is missing, then the latest will be used.
|
||||
|
||||
Generated by godownloader
|
||||
https://github.com/goreleaser/godownloader
|
||||
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
#BINDIR is ./bin unless set be ENV
|
||||
# over-ridden by flag below
|
||||
|
||||
BINDIR=${BINDIR:-./bin}
|
||||
while getopts "b:dh?x" arg; do
|
||||
case "$arg" in
|
||||
b) BINDIR="$OPTARG" ;;
|
||||
d) log_set_priority 10 ;;
|
||||
h | \?) usage "$0" ;;
|
||||
x) set -x ;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND - 1))
|
||||
TAG=$1
|
||||
}
|
||||
# this function wraps all the destructive operations
|
||||
# if a curl|bash cuts off the end of the script due to
|
||||
# network, either nothing will happen or will syntax error
|
||||
# out preventing half-done work
|
||||
execute() {
|
||||
tmpdir=$(mktemp -d)
|
||||
log_debug "downloading files into ${tmpdir}"
|
||||
http_download "${tmpdir}/${TARBALL}" "${TARBALL_URL}"
|
||||
http_download "${tmpdir}/${CHECKSUM}" "${CHECKSUM_URL}"
|
||||
hash_sha256_verify "${tmpdir}/${TARBALL}" "${tmpdir}/${CHECKSUM}"
|
||||
srcdir="${tmpdir}"
|
||||
(cd "${tmpdir}" && untar "${TARBALL}")
|
||||
test ! -d "${BINDIR}" && install -d "${BINDIR}"
|
||||
for binexe in $BINARIES; do
|
||||
if [ "$OS" = "windows" ]; then
|
||||
binexe="${binexe}.exe"
|
||||
fi
|
||||
install "${srcdir}/${binexe}" "${BINDIR}/"
|
||||
log_info "installed ${BINDIR}/${binexe}"
|
||||
done
|
||||
rm -rf "${tmpdir}"
|
||||
}
|
||||
get_binaries() {
|
||||
case "$PLATFORM" in
|
||||
darwin/386) BINARIES="goreleaser" ;;
|
||||
darwin/amd64) BINARIES="goreleaser" ;;
|
||||
darwin/arm64) BINARIES="goreleaser" ;;
|
||||
darwin/armv6) BINARIES="goreleaser" ;;
|
||||
linux/386) BINARIES="goreleaser" ;;
|
||||
linux/amd64) BINARIES="goreleaser" ;;
|
||||
linux/arm64) BINARIES="goreleaser" ;;
|
||||
linux/armv6) BINARIES="goreleaser" ;;
|
||||
windows/386) BINARIES="goreleaser" ;;
|
||||
windows/amd64) BINARIES="goreleaser" ;;
|
||||
windows/arm64) BINARIES="goreleaser" ;;
|
||||
windows/armv6) BINARIES="goreleaser" ;;
|
||||
*)
|
||||
log_crit "platform $PLATFORM is not supported. Make sure this script is up-to-date and file request at https://github.com/${PREFIX}/issues/new"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
tag_to_version() {
|
||||
if [ -z "${TAG}" ]; then
|
||||
log_info "checking GitHub for latest tag"
|
||||
else
|
||||
log_info "checking GitHub for tag '${TAG}'"
|
||||
fi
|
||||
REALTAG=$(github_release "$OWNER/$REPO" "${TAG}") && true
|
||||
if test -z "$REALTAG"; then
|
||||
log_crit "unable to find '${TAG}' - use 'latest' or see https://github.com/${PREFIX}/releases for details"
|
||||
exit 1
|
||||
fi
|
||||
# if version starts with 'v', remove it
|
||||
TAG="$REALTAG"
|
||||
VERSION=${TAG#v}
|
||||
}
|
||||
adjust_format() {
|
||||
# change format (tar.gz or zip) based on OS
|
||||
case ${OS} in
|
||||
windows) FORMAT=zip ;;
|
||||
esac
|
||||
true
|
||||
}
|
||||
adjust_os() {
|
||||
# adjust archive name based on OS
|
||||
case ${OS} in
|
||||
386) OS=i386 ;;
|
||||
amd64) OS=x86_64 ;;
|
||||
darwin) OS=Darwin ;;
|
||||
linux) OS=Linux ;;
|
||||
windows) OS=Windows ;;
|
||||
esac
|
||||
true
|
||||
}
|
||||
adjust_arch() {
|
||||
# adjust archive name based on ARCH
|
||||
case ${ARCH} in
|
||||
386) ARCH=i386 ;;
|
||||
amd64) ARCH=x86_64 ;;
|
||||
darwin) ARCH=Darwin ;;
|
||||
linux) ARCH=Linux ;;
|
||||
windows) ARCH=Windows ;;
|
||||
esac
|
||||
true
|
||||
}
|
||||
|
||||
cat /dev/null <<EOF
|
||||
------------------------------------------------------------------------
|
||||
https://github.com/client9/shlib - portable posix shell functions
|
||||
Public domain - http://unlicense.org
|
||||
https://github.com/client9/shlib/blob/master/LICENSE.md
|
||||
but credit (and pull requests) appreciated.
|
||||
------------------------------------------------------------------------
|
||||
EOF
|
||||
is_command() {
|
||||
command -v "$1" >/dev/null
|
||||
}
|
||||
echoerr() {
|
||||
echo "$@" 1>&2
|
||||
}
|
||||
log_prefix() {
|
||||
echo "$0"
|
||||
}
|
||||
_logp=6
|
||||
log_set_priority() {
|
||||
_logp="$1"
|
||||
}
|
||||
log_priority() {
|
||||
if test -z "$1"; then
|
||||
echo "$_logp"
|
||||
return
|
||||
fi
|
||||
[ "$1" -le "$_logp" ]
|
||||
}
|
||||
log_tag() {
|
||||
case $1 in
|
||||
0) echo "emerg" ;;
|
||||
1) echo "alert" ;;
|
||||
2) echo "crit" ;;
|
||||
3) echo "err" ;;
|
||||
4) echo "warning" ;;
|
||||
5) echo "notice" ;;
|
||||
6) echo "info" ;;
|
||||
7) echo "debug" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
log_debug() {
|
||||
log_priority 7 || return 0
|
||||
echoerr "$(log_prefix)" "$(log_tag 7)" "$@"
|
||||
}
|
||||
log_info() {
|
||||
log_priority 6 || return 0
|
||||
echoerr "$(log_prefix)" "$(log_tag 6)" "$@"
|
||||
}
|
||||
log_err() {
|
||||
log_priority 3 || return 0
|
||||
echoerr "$(log_prefix)" "$(log_tag 3)" "$@"
|
||||
}
|
||||
log_crit() {
|
||||
log_priority 2 || return 0
|
||||
echoerr "$(log_prefix)" "$(log_tag 2)" "$@"
|
||||
}
|
||||
uname_os() {
|
||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
case "$os" in
|
||||
cygwin_nt*) os="windows" ;;
|
||||
mingw*) os="windows" ;;
|
||||
msys_nt*) os="windows" ;;
|
||||
esac
|
||||
echo "$os"
|
||||
}
|
||||
uname_arch() {
|
||||
arch=$(uname -m)
|
||||
case $arch in
|
||||
x86_64) arch="amd64" ;;
|
||||
x86) arch="386" ;;
|
||||
i686) arch="386" ;;
|
||||
i386) arch="386" ;;
|
||||
aarch64) arch="arm64" ;;
|
||||
armv5*) arch="armv5" ;;
|
||||
armv6*) arch="armv6" ;;
|
||||
armv7*) arch="armv7" ;;
|
||||
esac
|
||||
echo ${arch}
|
||||
}
|
||||
uname_os_check() {
|
||||
os=$(uname_os)
|
||||
case "$os" in
|
||||
darwin) return 0 ;;
|
||||
dragonfly) return 0 ;;
|
||||
freebsd) return 0 ;;
|
||||
linux) return 0 ;;
|
||||
android) return 0 ;;
|
||||
nacl) return 0 ;;
|
||||
netbsd) return 0 ;;
|
||||
openbsd) return 0 ;;
|
||||
plan9) return 0 ;;
|
||||
solaris) return 0 ;;
|
||||
windows) return 0 ;;
|
||||
esac
|
||||
log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib"
|
||||
return 1
|
||||
}
|
||||
uname_arch_check() {
|
||||
arch=$(uname_arch)
|
||||
case "$arch" in
|
||||
386) return 0 ;;
|
||||
amd64) return 0 ;;
|
||||
arm64) return 0 ;;
|
||||
armv5) return 0 ;;
|
||||
armv6) return 0 ;;
|
||||
armv7) return 0 ;;
|
||||
ppc64) return 0 ;;
|
||||
ppc64le) return 0 ;;
|
||||
mips) return 0 ;;
|
||||
mipsle) return 0 ;;
|
||||
mips64) return 0 ;;
|
||||
mips64le) return 0 ;;
|
||||
s390x) return 0 ;;
|
||||
amd64p32) return 0 ;;
|
||||
esac
|
||||
log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib"
|
||||
return 1
|
||||
}
|
||||
untar() {
|
||||
tarball=$1
|
||||
case "${tarball}" in
|
||||
*.tar.gz | *.tgz) tar --no-same-owner -xzf "${tarball}" ;;
|
||||
*.tar) tar --no-same-owner -xf "${tarball}" ;;
|
||||
*.zip) unzip "${tarball}" ;;
|
||||
*)
|
||||
log_err "untar unknown archive format for ${tarball}"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
http_download_curl() {
|
||||
local_file=$1
|
||||
source_url=$2
|
||||
header=$3
|
||||
if [ -z "$header" ]; then
|
||||
code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url")
|
||||
else
|
||||
code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url")
|
||||
fi
|
||||
if [ "$code" != "200" ]; then
|
||||
log_debug "http_download_curl received HTTP status $code"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
http_download_wget() {
|
||||
local_file=$1
|
||||
source_url=$2
|
||||
header=$3
|
||||
if [ -z "$header" ]; then
|
||||
wget -q -O "$local_file" "$source_url"
|
||||
else
|
||||
wget -q --header "$header" -O "$local_file" "$source_url"
|
||||
fi
|
||||
}
|
||||
http_download() {
|
||||
log_debug "http_download $2"
|
||||
if is_command curl; then
|
||||
http_download_curl "$@"
|
||||
return
|
||||
elif is_command wget; then
|
||||
http_download_wget "$@"
|
||||
return
|
||||
fi
|
||||
log_crit "http_download unable to find wget or curl"
|
||||
return 1
|
||||
}
|
||||
http_copy() {
|
||||
tmp=$(mktemp)
|
||||
http_download "${tmp}" "$1" "$2" || return 1
|
||||
body=$(cat "$tmp")
|
||||
rm -f "${tmp}"
|
||||
echo "$body"
|
||||
}
|
||||
github_release() {
|
||||
owner_repo=$1
|
||||
version=$2
|
||||
test -z "$version" && version="latest"
|
||||
giturl="https://github.com/${owner_repo}/releases/${version}"
|
||||
json=$(http_copy "$giturl" "Accept:application/json")
|
||||
test -z "$json" && return 1
|
||||
version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//')
|
||||
test -z "$version" && return 1
|
||||
echo "$version"
|
||||
}
|
||||
hash_sha256() {
|
||||
TARGET=${1:-/dev/stdin}
|
||||
if is_command gsha256sum; then
|
||||
hash=$(gsha256sum "$TARGET") || return 1
|
||||
echo "$hash" | cut -d ' ' -f 1
|
||||
elif is_command sha256sum; then
|
||||
hash=$(sha256sum "$TARGET") || return 1
|
||||
echo "$hash" | cut -d ' ' -f 1
|
||||
elif is_command shasum; then
|
||||
hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1
|
||||
echo "$hash" | cut -d ' ' -f 1
|
||||
elif is_command openssl; then
|
||||
hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1
|
||||
echo "$hash" | cut -d ' ' -f a
|
||||
else
|
||||
log_crit "hash_sha256 unable to find command to compute sha-256 hash"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
hash_sha256_verify() {
|
||||
TARGET=$1
|
||||
checksums=$2
|
||||
if [ -z "$checksums" ]; then
|
||||
log_err "hash_sha256_verify checksum file not specified in arg2"
|
||||
return 1
|
||||
fi
|
||||
BASENAME=${TARGET##*/}
|
||||
want=$(grep "${BASENAME}$" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1)
|
||||
if [ -z "$want" ]; then
|
||||
log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'"
|
||||
return 1
|
||||
fi
|
||||
got=$(hash_sha256 "$TARGET")
|
||||
if [ "$want" != "$got" ]; then
|
||||
log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
cat /dev/null <<EOF
|
||||
------------------------------------------------------------------------
|
||||
End of functions from https://github.com/client9/shlib
|
||||
------------------------------------------------------------------------
|
||||
EOF
|
||||
|
||||
PROJECT_NAME="goreleaser"
|
||||
OWNER=goreleaser
|
||||
REPO="goreleaser"
|
||||
BINARY=goreleaser
|
||||
FORMAT=tar.gz
|
||||
OS=$(uname_os)
|
||||
ARCH=$(uname_arch)
|
||||
PREFIX="$OWNER/$REPO"
|
||||
|
||||
# use in logging routines
|
||||
log_prefix() {
|
||||
echo "$PREFIX"
|
||||
}
|
||||
PLATFORM="${OS}/${ARCH}"
|
||||
GITHUB_DOWNLOAD=https://github.com/${OWNER}/${REPO}/releases/download
|
||||
|
||||
uname_os_check "$OS"
|
||||
uname_arch_check "$ARCH"
|
||||
|
||||
parse_args "$@"
|
||||
|
||||
get_binaries
|
||||
|
||||
tag_to_version
|
||||
|
||||
adjust_format
|
||||
|
||||
adjust_os
|
||||
|
||||
adjust_arch
|
||||
|
||||
log_info "found version: ${VERSION} for ${TAG}/${OS}/${ARCH}"
|
||||
|
||||
NAME=${PROJECT_NAME}_${OS}_${ARCH}
|
||||
TARBALL=${NAME}.${FORMAT}
|
||||
TARBALL_URL=${GITHUB_DOWNLOAD}/${TAG}/${TARBALL}
|
||||
CHECKSUM=checksums.txt
|
||||
CHECKSUM_URL=${GITHUB_DOWNLOAD}/${TAG}/${CHECKSUM}
|
||||
|
||||
execute
|
||||
log_err "this script is deprecated, please do not use it anymore. check https://github.com/goreleaser/godownloader/issues/207"
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
|
||||
if [ "$(git status --porcelain | wc -l)" -ne "0" ]; then
|
||||
echo " 🔴 there are uncommitted changes, please commit them before running this check"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! make generate-json-schema; then
|
||||
echo "Generating json schema failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(git status --porcelain | wc -l)" -ne "0" ]; then
|
||||
echo " 🔴 there are uncommitted changes, please commit them before running this check"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,249 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import glob
|
||||
import subprocess
|
||||
import os
|
||||
import re
|
||||
|
||||
DRY_RUN = False
|
||||
|
||||
JSON_SCHEMA_LABEL = "json-schema"
|
||||
|
||||
# note: we can't use "breaking-change" as the label since that might be applied manually by a user. This is a
|
||||
# distinct label that we can use to indicate that the label was applied (or removed) by automation.
|
||||
BREAKING_CHANGE_LABEL = "detected-breaking-change"
|
||||
|
||||
|
||||
def main(changed_files: str | None = None, merge_base_schema_files: str | None = None):
|
||||
global DRY_RUN
|
||||
|
||||
pr_number = os.environ.get("GITHUB_PR_NUMBER")
|
||||
comment_file_path = os.environ.get("CI_COMMENT_FILE")
|
||||
|
||||
if not comment_file_path:
|
||||
print("CI_COMMENT_FILE not set")
|
||||
sys.exit(1)
|
||||
|
||||
if not pr_number:
|
||||
DRY_RUN = True
|
||||
|
||||
if changed_files:
|
||||
DRY_RUN = True
|
||||
|
||||
# read lines from file... this is useful for local testing
|
||||
with open(changed_files) as f:
|
||||
pr_changed_files = f.read().splitlines()
|
||||
|
||||
with open(merge_base_schema_files) as f:
|
||||
og_json_schema_files = sort_json_schema_files(f.read().splitlines())
|
||||
|
||||
else:
|
||||
if not is_ci():
|
||||
print("Not in CI")
|
||||
sys.exit(1)
|
||||
|
||||
if not pr_number:
|
||||
print("Not a PR")
|
||||
sys.exit(1)
|
||||
|
||||
pr_changed_files = get_pr_changed_files(pr_number)
|
||||
# since we are running this in the context of the pull_request_target, the checkout is the merge base..
|
||||
# that is the main branch of the original repo, NOT the branch in the forked repo (or branch in the target
|
||||
# repo for non-forked PRs). This means we just need to list the current checkedout files to get a sense of
|
||||
# the changes before a merge.
|
||||
og_json_schema_files = list_json_schema_files()
|
||||
|
||||
pr_json_schema_files = filter_to_schema_files(pr_changed_files)
|
||||
|
||||
pr_labels = get_pr_labels(pr_number)
|
||||
|
||||
# print("schema files in pr: ", summarize_schema_files(pr_json_schema_files))
|
||||
# print("og schema files: ", summarize_schema_files(og_json_schema_files))
|
||||
|
||||
if not og_json_schema_files:
|
||||
print("No schema files found in merge base")
|
||||
sys.exit(1)
|
||||
|
||||
# pr_json_schema_files = set of PR files are added, removed, and changed files
|
||||
new_schema_files = set(pr_json_schema_files) - set(og_json_schema_files)
|
||||
removed_or_modified_schema_files = set(pr_json_schema_files) - set(new_schema_files)
|
||||
|
||||
print("new schemas: ", summarize_schema_files(new_schema_files))
|
||||
print("removed or modified schemas:", summarize_schema_files(removed_or_modified_schema_files))
|
||||
|
||||
# if there is a new or modified schema, we should add the "json-schema" label to the PR...
|
||||
if new_schema_files or removed_or_modified_schema_files:
|
||||
print("\nAdding json-schema label...")
|
||||
add_label(pr_number, JSON_SCHEMA_LABEL)
|
||||
|
||||
else:
|
||||
if JSON_SCHEMA_LABEL in pr_labels:
|
||||
remove_label(pr_number, JSON_SCHEMA_LABEL)
|
||||
|
||||
# new schema files should be scrutinized, comparing the latest and added versions to see if it's a breaking
|
||||
# change (major version bump). Warn about it on the PR via adding a breaking-change label...
|
||||
if is_breaking_change(new_schema_files, og_json_schema_files[-1]):
|
||||
print("\nBreaking change detected...")
|
||||
add_label(pr_number, BREAKING_CHANGE_LABEL)
|
||||
else:
|
||||
if BREAKING_CHANGE_LABEL in pr_labels:
|
||||
remove_label(pr_number, BREAKING_CHANGE_LABEL)
|
||||
|
||||
# modifying an existing schema could be a breaking change, we should warn about it on the PR via a comment...
|
||||
# removing schema files should never be allowed, we should warn about it on the PR via a comment...
|
||||
if removed_or_modified_schema_files:
|
||||
print("\nRemoved or modified schema detected...")
|
||||
schemas = sort_json_schema_files(list(removed_or_modified_schema_files))
|
||||
schemas_str = "\n".join([f" - {schema}" for schema in schemas])
|
||||
add_comment(comment_file_path, f"Detected modification or removal of existing json schemas:\n{schemas_str}", warning=True)
|
||||
|
||||
|
||||
def add_comment(comment_file_path: str, comment: str, warning: bool = False, important: bool = False):
|
||||
if warning or important:
|
||||
comment_lines = comment.splitlines()
|
||||
comment = "\n".join([f"> {line}" for line in comment_lines])
|
||||
|
||||
if warning:
|
||||
comment = f"> [!WARNING]\n{comment}"
|
||||
elif important:
|
||||
comment = f"> [!IMPORTANT]\n{comment}"
|
||||
|
||||
# create any parent directories if they don't exist
|
||||
os.makedirs(os.path.dirname(comment_file_path), exist_ok=True)
|
||||
|
||||
with open(comment_file_path, "w") as f:
|
||||
f.write(comment)
|
||||
|
||||
print(f"Comment file contents: {comment_file_path}")
|
||||
print(comment)
|
||||
|
||||
|
||||
def add_label(pr_number: str, label: str):
|
||||
# run "gh pr edit --add-label <label>"
|
||||
result = run(f"gh pr edit {pr_number} --add-label {label}", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if result.returncode != 0:
|
||||
print(f"Unable to add '{label!r}' label to PR, error:")
|
||||
print(str(result.stderr))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def remove_label(pr_number: str, label: str):
|
||||
# run "gh pr edit --remove-label <label>"
|
||||
result = run(f"gh pr edit {pr_number} --remove-label {label}", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if result.returncode != 0:
|
||||
print(f"Unable to remove '{label!r}' label from PR, error:")
|
||||
print(str(result.stderr))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def major_version(semver: str) -> int:
|
||||
return int(semver.split(".")[0])
|
||||
|
||||
|
||||
def is_breaking_change(new_schema_files: set[str], latest_schema_file: str) -> bool:
|
||||
latest_major_version = major_version(get_semver(latest_schema_file))
|
||||
for file in new_schema_files:
|
||||
change_major_version = major_version(get_semver(file))
|
||||
if change_major_version > latest_major_version:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def summarize_schema_files(files: list[str]) -> list[str]:
|
||||
return [get_semver(file) for file in files]
|
||||
|
||||
|
||||
def is_ci() -> bool:
|
||||
return "CI" in os.environ
|
||||
|
||||
|
||||
def get_pr_changed_files(pr_number: str) -> list[str]:
|
||||
result = run(f"gh pr view {pr_number} --json files --jq '.files.[].path'", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if result.returncode != 0:
|
||||
print("Unable to get list of changed files in PR")
|
||||
print(str(result.stderr))
|
||||
sys.exit(1)
|
||||
|
||||
list_of_files = result.stdout.splitlines()
|
||||
return list_of_files
|
||||
|
||||
|
||||
def get_pr_labels(pr_number: str) -> list[str]:
|
||||
result = run(f"gh pr view {pr_number} --json labels --jq '.labels[].name'", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if result.returncode != 0:
|
||||
print("Unable to get list of labels on PR")
|
||||
print(str(result.stderr))
|
||||
sys.exit(1)
|
||||
|
||||
list_of_labels = result.stdout.splitlines()
|
||||
return list_of_labels
|
||||
|
||||
|
||||
def filter_to_schema_files(list_of_files: list[str]) -> list[str]:
|
||||
# get files matching "schema/json/schema-*.json"
|
||||
files = []
|
||||
for file in list_of_files:
|
||||
if re.match(r"^schema/json/schema-\d+\.\d+\.\d+\.json$", file):
|
||||
files.append(file)
|
||||
return sort_json_schema_files(files)
|
||||
|
||||
|
||||
def list_json_schema_files() -> list[str]:
|
||||
# list files in "schema/json" directory matching the pattern of "schema-*.json"
|
||||
# special case: always ignore the "latest" schema file
|
||||
return sort_json_schema_files([f for f in glob.glob("schema/json/schema-*.json") if "latest" not in f])
|
||||
|
||||
|
||||
def run(command: str, **kwargs) -> subprocess.CompletedProcess:
|
||||
if DRY_RUN:
|
||||
print(f"[DRY RUN] {command}")
|
||||
return subprocess.CompletedProcess(args=[command], returncode=0)
|
||||
print(f"[RUN] {command}")
|
||||
return subprocess.run(command, **kwargs)
|
||||
|
||||
|
||||
def get_semver(input_file: str) -> str:
|
||||
return input_file.split("-")[1].split(".json")[0]
|
||||
|
||||
|
||||
def sort_json_schema_files(files: list[str]) -> list[str]:
|
||||
# sort files by schema version, where the input looks like "schema/json/schema-1.12.1.json"
|
||||
# we should sort by the semantic version embedded within the basename, not the string
|
||||
# so that "schema/json/schema-1.2.1.json" comes before "schema/json/schema-1.12.1.json".
|
||||
versions = [get_semver(file) for file in files if file]
|
||||
|
||||
versions = sorted(versions, key=lambda s: [int(u) for u in s.split('.') if "." in s])
|
||||
|
||||
return [f"schema/json/schema-{version}.json" for version in versions]
|
||||
|
||||
|
||||
# allow for test files that have line-by-line list of files:
|
||||
|
||||
# .binny.yaml
|
||||
# .github/actions/bootstrap/action.yaml
|
||||
# .github/scripts/goreleaser-install.sh
|
||||
# .github/workflows/release.yaml
|
||||
# .github/workflows/update-bootstrap-tools.yml
|
||||
# .github/workflows/update-cpe-dictionary-index.yml
|
||||
# .github/workflows/update-stereoscope-release.yml
|
||||
# .github/workflows/validations.yaml
|
||||
# .gitignore
|
||||
# .goreleaser.yaml
|
||||
# Makefile
|
||||
# Taskfile.yaml
|
||||
# schema/cyclonedx/Makefile
|
||||
|
||||
if __name__ == "__main__":
|
||||
# these are variables for a single file name that contains a list of files (line separated)
|
||||
changed_files = None
|
||||
merge_base_schema_files = None
|
||||
|
||||
if len(sys.argv) > 2:
|
||||
changed_files = sys.argv[1]
|
||||
merge_base_schema_files = sys.argv[2]
|
||||
|
||||
main(changed_files, merge_base_schema_files)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import subprocess
|
||||
|
||||
import labeler
|
||||
|
||||
class Labeler(unittest.TestCase):
|
||||
|
||||
def test_major_version(self):
|
||||
self.assertEqual(labeler.major_version("1.2.3"), 1)
|
||||
self.assertEqual(labeler.major_version("2.0.0"), 2)
|
||||
|
||||
def test_is_breaking_change(self):
|
||||
new_schema_files = ["schema/json/schema-2.0.0.json"]
|
||||
latest_schema_file = "schema/json/schema-1.2.0.json"
|
||||
self.assertTrue(labeler.is_breaking_change(new_schema_files, latest_schema_file))
|
||||
|
||||
new_schema_files = ["schema/json/schema-1.3.0.json"]
|
||||
latest_schema_file = "schema/json/schema-1.2.0.json"
|
||||
self.assertFalse(labeler.is_breaking_change(new_schema_files, latest_schema_file))
|
||||
|
||||
def test_summarize_schema_files(self):
|
||||
files = ["schema/json/schema-1.0.0.json", "schema/json/schema-2.0.0.json"]
|
||||
expected = ["1.0.0", "2.0.0"]
|
||||
self.assertEqual(labeler.summarize_schema_files(files), expected)
|
||||
|
||||
def test_is_ci(self):
|
||||
# Mock os.environ to simulate CI environment
|
||||
with patch.dict("os.environ", {"CI": "true"}):
|
||||
self.assertTrue(labeler.is_ci())
|
||||
|
||||
def test_get_pr_changed_files(self):
|
||||
expected_command = "gh pr view 123 --json files --jq '.files.[].path'"
|
||||
expected_output = "file1.json\nfile2.json\n"
|
||||
|
||||
subprocess.CompletedProcess.returncode = 0
|
||||
subprocess.CompletedProcess.stdout = expected_output
|
||||
with patch("labeler.run", return_value=subprocess.CompletedProcess) as mock_run:
|
||||
result = labeler.get_pr_changed_files("123")
|
||||
mock_run.assert_called_with(expected_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
self.assertEqual(result, ["file1.json", "file2.json"])
|
||||
|
||||
def test_filter_to_schema_files(self):
|
||||
input_files = ["schema/json/schema-1.0.0.json", "not_schema.txt", "schema/json/schema-2.0.0.json"]
|
||||
expected_files = ["schema/json/schema-1.0.0.json", "schema/json/schema-2.0.0.json"]
|
||||
self.assertEqual(labeler.filter_to_schema_files(input_files), expected_files)
|
||||
|
||||
# we should be strict about what files are allowed to be processed
|
||||
input_files = ["schema/json/schema-1.0.0extracontent.json", "schema/json/schema-1.0.0.md", "schema/json/schema-1.0.0.json.extracontent"]
|
||||
expected_files = []
|
||||
self.assertEqual(labeler.filter_to_schema_files(input_files), expected_files)
|
||||
|
||||
def test_get_semver(self):
|
||||
input_file = "schema/json/schema-1.0.0.json"
|
||||
expected_semver = "1.0.0"
|
||||
self.assertEqual(labeler.get_semver(input_file), expected_semver)
|
||||
|
||||
def test_sort_json_schema_files(self):
|
||||
files = ["schema/json/schema-1.12.1.json", "schema/json/schema-1.2.1.json"]
|
||||
expected_sorted_files = ["schema/json/schema-1.2.1.json", "schema/json/schema-1.12.1.json"]
|
||||
self.assertEqual(labeler.sort_json_schema_files(files), expected_sorted_files)
|
||||
|
||||
# ensure that "latest" doesn't cause a problem and is ultimately ignored
|
||||
files = ["schema/json/schema-1.12.1.json", "schema/json/schema-_bogus.json"]
|
||||
expected_sorted_files = ["schema/json/schema-_bogus.json", "schema/json/schema-1.12.1.json"]
|
||||
self.assertEqual(labeler.sort_json_schema_files(files), expected_sorted_files)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
bold=$(tput bold)
|
||||
normal=$(tput sgr0)
|
||||
|
||||
GH_CLI=.tool/gh
|
||||
|
||||
if ! [ -x "$(command -v $GH_CLI)" ]; then
|
||||
echo "The GitHub CLI could not be found. run: make bootstrap"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$GH_CLI auth status
|
||||
|
||||
# set the default repo in cases where multiple remotes are defined
|
||||
$GH_CLI repo set-default anchore/syft
|
||||
|
||||
export GITHUB_TOKEN="${GITHUB_TOKEN-"$($GH_CLI auth token)"}"
|
||||
|
||||
# we need all of the git state to determine the next version. Since tagging is done by
|
||||
# the release pipeline it is possible to not have all of the tags from previous releases.
|
||||
git fetch --tags
|
||||
|
||||
# populates the CHANGELOG.md and VERSION files
|
||||
echo "${bold}Generating changelog...${normal}"
|
||||
make changelog 2> /dev/null
|
||||
|
||||
NEXT_VERSION=$(cat VERSION)
|
||||
|
||||
if [[ "$NEXT_VERSION" == "" || "${NEXT_VERSION}" == "(Unreleased)" ]]; then
|
||||
echo "Could not determine the next version to release. Exiting..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while true; do
|
||||
read -p "${bold}Do you want to trigger a release for version '${NEXT_VERSION}'?${normal} [y/n] " yn
|
||||
case $yn in
|
||||
[Yy]* ) echo; break;;
|
||||
[Nn]* ) echo; echo "Cancelling release..."; exit;;
|
||||
* ) echo "Please answer yes or no.";;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "${bold}Kicking off release for ${NEXT_VERSION}${normal}..."
|
||||
echo
|
||||
$GH_CLI workflow run release.yaml -f version=${NEXT_VERSION}
|
||||
|
||||
echo
|
||||
echo "${bold}Waiting for release to start...${normal}"
|
||||
sleep 10
|
||||
|
||||
set +e
|
||||
|
||||
echo "${bold}Head to the release workflow to monitor the release:${normal} $($GH_CLI run list --workflow=release.yaml --limit=1 --json url --jq '.[].url')"
|
||||
id=$($GH_CLI run list --workflow=release.yaml --limit=1 --json databaseId --jq '.[].databaseId')
|
||||
$GH_CLI run watch $id --exit-status || (echo ; echo "${bold}Logs of failed step:${normal}" && GH_PAGER="" $GH_CLI run view $id --log-failed)
|
||||
@@ -2,8 +2,8 @@
|
||||
set -ue
|
||||
|
||||
BIN="syft"
|
||||
VERSION_FILE="VERSION"
|
||||
VERSION=$1
|
||||
DISTDIR=$1
|
||||
VERSION=$2
|
||||
|
||||
# the source of truth as to whether we want to notify users of an update is if the release just created is NOT
|
||||
# flagged as a pre-release on github
|
||||
@@ -12,9 +12,10 @@ if [[ "$(curl -SsL https://api.github.com/repos/anchore/${BIN}/releases/tags/${V
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "creating and publishing version file (${VERSION})"
|
||||
echo "creating and publishing version file"
|
||||
|
||||
# create a version file for version-update checks
|
||||
VERSION_FILE="${DISTDIR}/VERSION"
|
||||
echo "${VERSION}" | tee "${VERSION_FILE}"
|
||||
|
||||
# upload the version file that supports the application version update check
|
||||
|
||||
@@ -7,23 +7,17 @@ name: "CodeQL Security Scan"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
# only run when there are pushes to the main branch (not on PRs)
|
||||
- main
|
||||
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 0 * * 3'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-22.04-4core-16gb
|
||||
if: github.repository == 'anchore/syft' # only run for main repo
|
||||
permissions:
|
||||
security-events: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -36,16 +30,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 #v5.5.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f #v3.28.18
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -56,7 +45,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f #v3.28.18
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -70,4 +59,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f #v3.28.18
|
||||
uses: github/codeql-action/analyze@v1
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
name: Dependabot Automation
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
run:
|
||||
uses: anchore/workflows/.github/workflows/dependabot-automation.yaml@main
|
||||
@@ -1,54 +0,0 @@
|
||||
name: "Detect schema changes"
|
||||
|
||||
on:
|
||||
# IMPORTANT! This workflow is triggered by the `pull_request_target` event
|
||||
# which means that forked PRs will run with access secrets from the repo
|
||||
# it's forked from (the "target" repo).
|
||||
#
|
||||
# For this reason we only NEVER checkout the code from the pull request
|
||||
# (e.g. "ref: ${{ github.event.pull_request.head.sha }}") to prevent
|
||||
# accidentally running potentially untrusted code.
|
||||
#
|
||||
# By default the checkout will be:
|
||||
# - GITHUB_SHA: Last commit on the PR base branch
|
||||
# - GITHUB_REF: PR base branch
|
||||
#
|
||||
# ...unlike a typical PR where:
|
||||
# - GITHUB_SHA: Last merge commit on the GITHUB_REF branch
|
||||
# - GITHUB_REF: PR merge branch refs/pull/:prNumber/merge
|
||||
pull_request_target:
|
||||
|
||||
env:
|
||||
# note: this is used within hashFiles() so must be within the GITHUB_WORKSPACE path (or will silently fail)
|
||||
CI_COMMENT_FILE: .tmp/labeler-comment.txt
|
||||
# needs to be any string to uniquely identify the comment on a PR across multiple runs
|
||||
COMMENT_HEADER: "label-commentary"
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: "Label changes"
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- run: python .github/scripts/labeler.py
|
||||
env:
|
||||
# note: this token has write access to the repo
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_PR_NUMBER: ${{ github.event.number }}
|
||||
|
||||
- name: Delete existing comment
|
||||
if: ${{ hashFiles( env.CI_COMMENT_FILE ) == '' }}
|
||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db #v2.9.2
|
||||
with:
|
||||
header: ${{ env.COMMENT_HEADER }}
|
||||
hide: true
|
||||
hide_classify: "OUTDATED"
|
||||
|
||||
- name: Add comment
|
||||
if: ${{ hashFiles( env.CI_COMMENT_FILE ) != '' }}
|
||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db #v2.9.2
|
||||
with:
|
||||
header: ${{ env.COMMENT_HEADER }}
|
||||
path: ${{ env.CI_COMMENT_FILE }}
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Add to OSS board
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- transferred
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
run:
|
||||
uses: "anchore/workflows/.github/workflows/oss-project-board-add.yaml@main"
|
||||
secrets:
|
||||
token: ${{ secrets.OSS_PROJECT_GH_TOKEN }}
|
||||
@@ -1,31 +0,0 @@
|
||||
name: "Release: version file"
|
||||
|
||||
on:
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: release version to update the version file with (prefixed with v)
|
||||
required: true
|
||||
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
description: release version to update the version file with (prefixed with v)
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Update version file
|
||||
run: make ci-release-version-file
|
||||
env:
|
||||
RELEASE_VERSION: ${{ github.event.inputs.version }}
|
||||
# for updating the VERSION file in S3...
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.TOOLBOX_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.TOOLBOX_AWS_SECRET_ACCESS_KEY }}
|
||||
@@ -1,38 +1,32 @@
|
||||
name: "Release"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: tag the latest commit on main with the given version (prefixed with v)
|
||||
required: true
|
||||
push:
|
||||
# take no actions on push to any branch...
|
||||
branches-ignore:
|
||||
- "**"
|
||||
# ... only act on release tags
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.16.x"
|
||||
|
||||
jobs:
|
||||
quality-gate:
|
||||
environment: release
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Check if running on main
|
||||
if: github.ref != 'refs/heads/main'
|
||||
# we are using the following flag when running `cosign blob-verify` for checksum signature verification:
|
||||
# --certificate-identity-regexp "https://github.com/anchore/.github/workflows/release.yaml@refs/heads/main"
|
||||
# if we are not on the main branch, the signature will not be verifiable since the suffix requires the main branch
|
||||
# at the time of when the OIDC token was issued on the Github Actions runner.
|
||||
run: echo "This can only be run on the main branch otherwise releases produced will not be verifiable with cosign" && exit 1
|
||||
|
||||
- name: Check if tag already exists
|
||||
# note: this will fail if the tag already exists
|
||||
# we don't want to release commits that have been pushed and tagged, but not necessarily merged onto main
|
||||
- name: Ensure tagged commit is on main
|
||||
run: |
|
||||
[[ "${{ github.event.inputs.version }}" == v* ]] || (echo "version '${{ github.event.inputs.version }}' does not have a 'v' prefix" && exit 1)
|
||||
git tag ${{ github.event.inputs.version }}
|
||||
echo "Tag: ${GITHUB_REF##*/}"
|
||||
git fetch origin main
|
||||
git merge-base --is-ancestor ${GITHUB_REF##*/} origin/main && echo "${GITHUB_REF##*/} is a commit on main!"
|
||||
|
||||
- name: Check static analysis results
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
uses: fountainhead/action-wait-for-check@v1.0.0
|
||||
id: static-analysis
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -41,7 +35,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Check unit test results
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
uses: fountainhead/action-wait-for-check@v1.0.0
|
||||
id: unit
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -50,7 +44,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Check integration test results
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
uses: fountainhead/action-wait-for-check@v1.0.0
|
||||
id: integration
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -59,7 +53,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Check acceptance test results (linux)
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
uses: fountainhead/action-wait-for-check@v1.0.0
|
||||
id: acceptance-linux
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -68,7 +62,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Check acceptance test results (mac)
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
uses: fountainhead/action-wait-for-check@v1.0.0
|
||||
id: acceptance-mac
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -77,7 +71,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Check cli test results (linux)
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
uses: fountainhead/action-wait-for-check@v1.0.0
|
||||
id: cli-linux
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -98,75 +92,69 @@ jobs:
|
||||
|
||||
release:
|
||||
needs: [quality-gate]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
# required for goreleaser signs section with cosign
|
||||
id-token: write
|
||||
# due to our code signing process, it's vital that we run our release steps on macOS
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- uses: docker-practice/actions-setup-docker@v1
|
||||
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0
|
||||
- name: Restore tool cache
|
||||
id: tool-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
username: ${{ secrets.ANCHOREOSSWRITE_DH_USERNAME }}
|
||||
password: ${{ secrets.ANCHOREOSSWRITE_DH_PAT }}
|
||||
path: ${{ github.workspace }}/.tmp
|
||||
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0
|
||||
- name: Restore go cache
|
||||
id: go-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ env.GO_VERSION }}-
|
||||
|
||||
- name: Tag release
|
||||
run: |
|
||||
git config --global user.name "anchoreci"
|
||||
git config --global user.email "anchoreci@users.noreply.github.com"
|
||||
git tag -a ${{ github.event.inputs.version }} -m "Release ${{ github.event.inputs.version }}"
|
||||
git push origin --tags
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: (cache-miss) Bootstrap all project dependencies
|
||||
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
|
||||
run: make bootstrap
|
||||
|
||||
- name: Build & publish release artifacts
|
||||
run: make ci-release
|
||||
run: make release
|
||||
env:
|
||||
# for mac signing and notarization...
|
||||
QUILL_SIGN_P12: ${{ secrets.ANCHORE_APPLE_DEVELOPER_ID_CERT_CHAIN }}
|
||||
QUILL_SIGN_PASSWORD: ${{ secrets.ANCHORE_APPLE_DEVELOPER_ID_CERT_PASS }}
|
||||
QUILL_NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_ISSUER }}
|
||||
QUILL_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
|
||||
QUILL_NOTARY_KEY: ${{ secrets.APPLE_NOTARY_KEY }}
|
||||
# for creating the release (requires write access to packages and content)
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# for updating brew formula in anchore/homebrew-syft
|
||||
GITHUB_BREW_TOKEN: ${{ secrets.ANCHOREOPS_GITHUB_OSS_WRITE_TOKEN }}
|
||||
DOCKER_USERNAME: ${{ secrets.TOOLBOX_DOCKER_USER }}
|
||||
DOCKER_PASSWORD: ${{ secrets.TOOLBOX_DOCKER_PASS }}
|
||||
# we use a different token than GITHUB_SECRETS to additionally allow updating the homebrew repos
|
||||
GITHUB_TOKEN: ${{ secrets.ANCHORE_GIT_READ_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.TOOLBOX_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.TOOLBOX_AWS_SECRET_ACCESS_KEY }}
|
||||
# used during macOS code signing
|
||||
APPLE_DEVELOPER_ID_CERT: ${{ secrets.APPLE_DEVELOPER_ID_CERT }}
|
||||
APPLE_DEVELOPER_ID_CERT_PASS: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASS }}
|
||||
# used during macOS notarization
|
||||
AC_USERNAME: ${{ secrets.ENG_CI_APPLE_ID }}
|
||||
AC_PASSWORD: ${{ secrets.ENG_CI_APPLE_ID_PASS }}
|
||||
|
||||
- uses: anchore/sbom-action@e11c554f704a0b820cbf8c51673f6945e0731532 #v0.20.0
|
||||
continue-on-error: true
|
||||
- uses: anchore/sbom-action@v0
|
||||
with:
|
||||
file: go.mod
|
||||
artifact-name: sbom.spdx.json
|
||||
|
||||
- uses: 8398a7/action-slack@1750b5085f3ec60384090fb7c52965ef822e869e #v3.18.0
|
||||
continue-on-error: true
|
||||
- uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: ${{ job.status }}
|
||||
fields: repo,workflow,action,eventName
|
||||
text: "A new Syft release has been published: https://github.com/anchore/syft/releases/tag/${{ github.event.inputs.version }}"
|
||||
text: "A new Syft release is ready to be manually published: https://github.com/anchore/syft/releases"
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }}
|
||||
if: ${{ success() }}
|
||||
|
||||
release-version-file:
|
||||
needs: [release]
|
||||
uses: ./.github/workflows/release-version-file.yaml
|
||||
with:
|
||||
version: ${{ github.event.inputs.version }}
|
||||
secrets: inherit
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: artifacts
|
||||
path: dist/**/*
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
name: "Manage Awaiting Response Label"
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
run:
|
||||
uses: "anchore/workflows/.github/workflows/remove-awaiting-response-label.yaml@main"
|
||||
secrets:
|
||||
token: ${{ secrets.OSS_PROJECT_GH_TOKEN }}
|
||||
@@ -1,40 +0,0 @@
|
||||
name: "Test fixture cache: publish"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# run nightly at 4AM UTC
|
||||
- cron: "0 4 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
Publish:
|
||||
name: "Publish test fixture image cache"
|
||||
# we use this runner to get enough storage space for docker images and fixture cache
|
||||
runs-on: ubuntu-22.04-4core-16gb
|
||||
if: github.repository == 'anchore/syft' # only run for main repo
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
with:
|
||||
# we want to rebuild the cache with no previous state
|
||||
download-test-fixture-cache: false
|
||||
|
||||
- name: Run all tests
|
||||
run: make test
|
||||
env:
|
||||
# we want to rebuild the cache with no previous state
|
||||
DOWNLOAD_TEST_FIXTURE_CACHE: "false"
|
||||
|
||||
- name: Login to GitHub Container Registry (ORAS)
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | .tool/oras login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Publish test fixture cache
|
||||
run: make upload-test-fixture-cache
|
||||
@@ -1,49 +0,0 @@
|
||||
name: PR to update Anchore dependencies
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repos:
|
||||
description: "List of dependencies to update"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'anchore' # only run for main repo (not forks)
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
with:
|
||||
tools: false
|
||||
bootstrap-apt-packages: ""
|
||||
|
||||
- name: Update dependencies
|
||||
id: update
|
||||
uses: anchore/workflows/.github/actions/update-go-dependencies@main
|
||||
with:
|
||||
repos: ${{ github.event.inputs.repos }}
|
||||
|
||||
- uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a #v2.1.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.TOKEN_APP_ID }}
|
||||
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
||||
with:
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
draft: ${{ steps.update.outputs.draft }}
|
||||
# do not change this branch, as other workflows depend on it
|
||||
branch: auto/integration
|
||||
labels: dependencies,pre-release
|
||||
commit-message: "chore(deps): update anchore dependencies"
|
||||
title: "chore(deps): update anchore dependencies"
|
||||
body: ${{ steps.update.outputs.summary }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
@@ -1,65 +0,0 @@
|
||||
name: PR for latest versions of tools
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * *" # 3 AM EST
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-bootstrap-tools:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'anchore/syft' # only run for main repo
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
with:
|
||||
bootstrap-apt-packages: ""
|
||||
go-dependencies: false
|
||||
|
||||
- name: "Update tool versions"
|
||||
id: latest-versions
|
||||
run: |
|
||||
make update-tools
|
||||
make list-tools
|
||||
|
||||
export NO_COLOR=1
|
||||
delimiter="$(openssl rand -hex 8)"
|
||||
|
||||
{
|
||||
echo "status<<${delimiter}"
|
||||
make list-tool-updates
|
||||
echo "${delimiter}"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
{
|
||||
echo "### Tool version status"
|
||||
echo "\`\`\`"
|
||||
make list-tool-updates
|
||||
echo "\`\`\`"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a #v2.1.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.TOKEN_APP_ID }}
|
||||
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
||||
with:
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
branch: auto/latest-tools
|
||||
labels: dependencies
|
||||
commit-message: 'chore(deps): update tools to latest versions'
|
||||
title: 'chore(deps): update tools to latest versions'
|
||||
body: |
|
||||
```
|
||||
${{ steps.latest-versions.outputs.status }}
|
||||
```
|
||||
This is an auto-generated pull request to update all of the tools to the latest versions.
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
@@ -1,55 +0,0 @@
|
||||
name: PR to update CPE dictionary index
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 1 * * 1" # every monday at 1 AM
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SLACK_NOTIFICATIONS: true
|
||||
|
||||
jobs:
|
||||
upgrade-cpe-dictionary-index:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'anchore/syft' # only run for main repo
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
|
||||
- run: |
|
||||
make generate-cpe-dictionary-index
|
||||
|
||||
- uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a #v2.1.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.TOKEN_APP_ID }}
|
||||
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
||||
with:
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
branch: auto/latest-cpe-dictionary-index
|
||||
labels: dependencies
|
||||
commit-message: "chore(deps): update CPE dictionary index"
|
||||
title: "chore(deps): update CPE dictionary index"
|
||||
body: |
|
||||
Update CPE dictionary index based on the latest available CPE dictionary
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- uses: 8398a7/action-slack@1750b5085f3ec60384090fb7c52965ef822e869e #v3.18.0
|
||||
with:
|
||||
status: ${{ job.status }}
|
||||
fields: workflow,eventName,job
|
||||
text: Syft CPE dictionary index update failed
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }}
|
||||
if: ${{ failure() && env.SLACK_NOTIFICATIONS == 'true' }}
|
||||
+268
-178
@@ -1,181 +1,294 @@
|
||||
name: "Validations"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
GO_VERSION: "1.16.x"
|
||||
|
||||
jobs:
|
||||
|
||||
Static-Analysis:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Static analysis"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Restore tool cache
|
||||
id: tool-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ${{ github.workspace }}/.tmp
|
||||
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
|
||||
|
||||
- name: Restore go cache
|
||||
id: go-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ env.GO_VERSION }}-
|
||||
|
||||
- name: (cache-miss) Bootstrap all project dependencies
|
||||
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
|
||||
run: make bootstrap
|
||||
|
||||
- name: Bootstrap CI environment dependencies
|
||||
run: make ci-bootstrap
|
||||
|
||||
- name: Run static analysis
|
||||
run: make static-analysis
|
||||
|
||||
|
||||
Unit-Test:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Unit tests"
|
||||
# we need more storage than what's on the default runner
|
||||
runs-on: ubuntu-22.04-4core-16gb
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
download-test-fixture-cache: true
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Restore tool cache
|
||||
id: tool-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ${{ github.workspace }}/.tmp
|
||||
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
|
||||
|
||||
- name: Restore go cache
|
||||
id: go-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ env.GO_VERSION }}-
|
||||
|
||||
- name: (cache-miss) Bootstrap all project dependencies
|
||||
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
|
||||
run: make bootstrap
|
||||
|
||||
- name: Bootstrap CI environment dependencies
|
||||
run: make ci-bootstrap
|
||||
|
||||
- name: Build cache key for java test-fixture blobs (for unit tests)
|
||||
run: make java-packages-fingerprint
|
||||
|
||||
- name: Restore Java test-fixture cache
|
||||
id: unit-java-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: syft/pkg/cataloger/java/test-fixtures/java-builds/packages
|
||||
key: ${{ runner.os }}-unit-java-cache-${{ hashFiles( 'syft/pkg/cataloger/java/test-fixtures/java-builds/packages.fingerprint' ) }}
|
||||
|
||||
- name: Run unit tests
|
||||
run: make unit
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: unit-test-results
|
||||
path: test/results/**/*
|
||||
|
||||
Integration-Test:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Integration tests"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
download-test-fixture-cache: true
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Restore tool cache
|
||||
id: tool-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ${{ github.workspace }}/.tmp
|
||||
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
|
||||
|
||||
- name: Restore go cache
|
||||
id: go-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ env.GO_VERSION }}-
|
||||
|
||||
- name: (cache-miss) Bootstrap all project dependencies
|
||||
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
|
||||
run: make bootstrap
|
||||
|
||||
- name: Bootstrap CI environment dependencies
|
||||
run: make ci-bootstrap
|
||||
|
||||
- name: Validate syft output against the CycloneDX schema
|
||||
run: make validate-cyclonedx-schema
|
||||
|
||||
- name: Build key for tar cache
|
||||
run: make integration-fingerprint
|
||||
|
||||
- name: Restore integration test cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ${{ github.workspace }}/test/integration/test-fixtures/cache
|
||||
key: ${{ runner.os }}-integration-test-cache-${{ hashFiles('test/integration/test-fixtures/cache.fingerprint') }}
|
||||
|
||||
- name: Run integration tests
|
||||
run: make integration
|
||||
|
||||
Benchmark-Test:
|
||||
name: "Benchmark tests"
|
||||
runs-on: ubuntu-20.04
|
||||
# note: we want benchmarks to run on pull_request events in order to publish results to a sticky comment, and
|
||||
# we also want to run on push such that merges to main are recorded to the cache. For this reason we don't filter
|
||||
# the job by event.
|
||||
steps:
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Restore tool cache
|
||||
id: tool-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ${{ github.workspace }}/.tmp
|
||||
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
|
||||
|
||||
- name: Restore go cache
|
||||
id: go-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ env.GO_VERSION }}-
|
||||
|
||||
- name: (cache-miss) Bootstrap all project dependencies
|
||||
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
|
||||
run: make bootstrap
|
||||
|
||||
- name: Bootstrap CI environment dependencies
|
||||
run: make ci-bootstrap
|
||||
|
||||
- name: Restore base benchmark result
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: test/results/benchmark-main.txt
|
||||
# use base sha for PR or new commit hash for main push in benchmark result key
|
||||
key: ${{ runner.os }}-bench-${{ (github.event.pull_request.base.sha != github.event.after) && github.event.pull_request.base.sha || github.event.after }}
|
||||
|
||||
- name: Run benchmark tests
|
||||
id: benchmark
|
||||
run: |
|
||||
REF_NAME=${GITHUB_REF##*/} make benchmark
|
||||
OUTPUT=$(make show-benchstat)
|
||||
OUTPUT="${OUTPUT//'%'/'%25'}" # URL encode all '%' characters
|
||||
OUTPUT="${OUTPUT//$'\n'/'%0A'}" # URL encode all '\n' characters
|
||||
OUTPUT="${OUTPUT//$'\r'/'%0D'}" # URL encode all '\r' characters
|
||||
echo "::set-output name=result::$OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: benchmark-test-results
|
||||
path: test/results/**/*
|
||||
|
||||
- name: Update PR benchmark results comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
continue-on-error: true
|
||||
with:
|
||||
header: benchmark
|
||||
message: |
|
||||
### Benchmark Test Results
|
||||
|
||||
<details>
|
||||
<summary>Benchmark results from the latest changes vs base branch</summary>
|
||||
|
||||
```
|
||||
${{ steps.benchmark.outputs.result }}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Build-Snapshot-Artifacts:
|
||||
name: "Build snapshot artifacts"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
bootstrap-apt-packages: ""
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Restore tool cache
|
||||
id: tool-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ${{ github.workspace }}/.tmp
|
||||
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
|
||||
|
||||
- name: Restore go cache
|
||||
id: go-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ env.GO_VERSION }}-
|
||||
|
||||
- name: (cache-miss) Bootstrap all project dependencies
|
||||
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
|
||||
run: make bootstrap
|
||||
|
||||
- name: Build snapshot artifacts
|
||||
run: make snapshot
|
||||
|
||||
# why not use actions/upload-artifact? It is very slow (3 minutes to upload ~600MB of data, vs 10 seconds with this approach).
|
||||
# see https://github.com/actions/upload-artifact/issues/199 for more info
|
||||
- name: Upload snapshot artifacts
|
||||
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
# we need to preserve the snapshot data itself as well as the task data that confirms if the
|
||||
# snapshot build is stale or not. Otherwise the downstream jobs will attempt to rebuild the snapshot
|
||||
# even though it already exists.
|
||||
path: |
|
||||
snapshot
|
||||
.task
|
||||
key: snapshot-build-${{ github.run_id }}
|
||||
|
||||
|
||||
Upload-Snapshot-Artifacts:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Upload snapshot artifacts"
|
||||
needs: [Build-Snapshot-Artifacts]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
with:
|
||||
download-test-fixture-cache: true
|
||||
|
||||
- name: Download snapshot build
|
||||
id: snapshot-cache
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||
with:
|
||||
path: |
|
||||
snapshot
|
||||
.task
|
||||
fail-on-cache-miss: true
|
||||
key: snapshot-build-${{ github.run_id }}
|
||||
|
||||
# workaround for https://github.com/actions/cache/issues/1265
|
||||
- name: (cache-miss) Snapshot build missing
|
||||
if: steps.snapshot-cache.outputs.cache-hit != 'true'
|
||||
run: echo "unable to download snapshots from previous job" && false
|
||||
|
||||
- run: npm install @actions/artifact@2.2.2
|
||||
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { readdirSync } = require('fs')
|
||||
const { DefaultArtifactClient } = require('@actions/artifact')
|
||||
const artifact = new DefaultArtifactClient()
|
||||
const ls = d => readdirSync(d, { withFileTypes: true })
|
||||
const baseDir = "./snapshot"
|
||||
const dirs = ls(baseDir).filter(f => f.isDirectory()).map(f => f.name)
|
||||
const uploads = []
|
||||
for (const dir of dirs) {
|
||||
// uploadArtifact returns Promise<{id, size}>
|
||||
uploads.push(artifact.uploadArtifact(
|
||||
// name of the archive:
|
||||
`${dir}`,
|
||||
// array of all files to include:
|
||||
ls(`${baseDir}/${dir}`).map(f => `${baseDir}/${dir}/${f.name}`),
|
||||
// base directory to trim from entries:
|
||||
`${baseDir}/${dir}`,
|
||||
{ retentionDays: 30 }
|
||||
))
|
||||
}
|
||||
// wait for all uploads to finish
|
||||
Promise.all(uploads)
|
||||
name: artifacts
|
||||
path: snapshot/**/*
|
||||
|
||||
Acceptance-Linux:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Acceptance tests (Linux)"
|
||||
needs: [Build-Snapshot-Artifacts]
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
download-test-fixture-cache: true
|
||||
|
||||
- name: Download snapshot build
|
||||
id: snapshot-cache
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||
with:
|
||||
path: |
|
||||
snapshot
|
||||
.task
|
||||
fail-on-cache-miss: true
|
||||
key: snapshot-build-${{ github.run_id }}
|
||||
|
||||
# workaround for https://github.com/actions/cache/issues/1265
|
||||
- name: (cache-miss) Snapshot build missing
|
||||
if: steps.snapshot-cache.outputs.cache-hit != 'true'
|
||||
run: echo "unable to download snapshots from previous job" && false
|
||||
name: artifacts
|
||||
path: snapshot
|
||||
|
||||
- name: Run comparison tests (Linux)
|
||||
run: make compare-linux
|
||||
|
||||
- name: Build key for image cache
|
||||
run: make install-fingerprint
|
||||
|
||||
- name: Restore install.sh test image cache
|
||||
id: install-test-image-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ${{ github.workspace }}/test/install/cache
|
||||
key: ${{ runner.os }}-install-test-image-cache-${{ hashFiles('test/install/cache.fingerprint') }}
|
||||
|
||||
- name: Load test image cache
|
||||
if: steps.install-test-image-cache.outputs.cache-hit == 'true'
|
||||
run: make install-test-cache-load
|
||||
@@ -194,32 +307,19 @@ jobs:
|
||||
needs: [Build-Snapshot-Artifacts]
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@v3.8.2
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
bootstrap-apt-packages: ""
|
||||
go-dependencies: false
|
||||
download-test-fixture-cache: true
|
||||
name: artifacts
|
||||
path: snapshot
|
||||
|
||||
- name: Download snapshot build
|
||||
id: snapshot-cache
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||
- name: Restore docker image cache for compare testing
|
||||
id: mac-compare-testing-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: |
|
||||
snapshot
|
||||
.task
|
||||
fail-on-cache-miss: true
|
||||
key: snapshot-build-${{ github.run_id }}
|
||||
|
||||
# workaround for https://github.com/actions/cache/issues/1265
|
||||
- name: (cache-miss) Snapshot build missing
|
||||
if: steps.snapshot-cache.outputs.cache-hit != 'true'
|
||||
run: echo "unable to download snapshots from previous job" && false
|
||||
path: image.tar
|
||||
key: ${{ runner.os }}-${{ hashFiles('test/compare/mac.sh') }}
|
||||
|
||||
- name: Run comparison tests (Mac)
|
||||
run: make compare-mac
|
||||
@@ -227,54 +327,44 @@ jobs:
|
||||
- name: Run install.sh tests (Mac)
|
||||
run: make install-test-ci-mac
|
||||
|
||||
|
||||
Cli-Linux:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "CLI tests (Linux)"
|
||||
needs: [Build-Snapshot-Artifacts]
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
download-test-fixture-cache: true
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Download snapshot build
|
||||
id: snapshot-cache
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Restore go cache
|
||||
id: go-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: |
|
||||
snapshot
|
||||
.task
|
||||
fail-on-cache-miss: true
|
||||
key: snapshot-build-${{ github.run_id }}
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ env.GO_VERSION }}-
|
||||
|
||||
# workaround for https://github.com/actions/cache/issues/1265
|
||||
- name: (cache-miss) Snapshot build missing
|
||||
if: steps.snapshot-cache.outputs.cache-hit != 'true'
|
||||
run: echo "unable to download snapshots from previous job" && false
|
||||
- name: (cache-miss) Bootstrap go dependencies
|
||||
if: steps.go-cache.outputs.cache-hit != 'true'
|
||||
run: make bootstrap-go
|
||||
|
||||
- name: Build key for tar cache
|
||||
run: make cli-fingerprint
|
||||
|
||||
- name: Restore CLI test cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: ${{ github.workspace }}/test/cli/test-fixtures/cache
|
||||
key: ${{ runner.os }}-cli-test-cache-${{ hashFiles('test/cli/test-fixtures/cache.fingerprint') }}
|
||||
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: artifacts
|
||||
path: snapshot
|
||||
|
||||
- name: Run CLI Tests (Linux)
|
||||
run: make cli
|
||||
|
||||
|
||||
Cleanup-Cache:
|
||||
name: "Cleanup snapshot cache"
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
actions: write
|
||||
needs:
|
||||
- Acceptance-Linux
|
||||
- Acceptance-Mac
|
||||
- Cli-Linux
|
||||
- Upload-Snapshot-Artifacts
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Delete snapshot cache
|
||||
run: gh cache delete "snapshot-build-${{ github.run_id }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
+10
-43
@@ -1,40 +1,11 @@
|
||||
# local development tailoring
|
||||
go.work
|
||||
go.work.sum
|
||||
.tool-versions
|
||||
|
||||
# app configuration
|
||||
/.syft.yaml
|
||||
|
||||
# tool and bin directories
|
||||
.tmp/
|
||||
bin/
|
||||
/bin
|
||||
/.bin
|
||||
/build
|
||||
CHANGELOG.md
|
||||
/test/results
|
||||
/dist
|
||||
/snapshot
|
||||
/.tool
|
||||
/.task
|
||||
|
||||
# changelog generation
|
||||
CHANGELOG.md
|
||||
VERSION
|
||||
|
||||
# IDE configuration
|
||||
.vscode/
|
||||
.idea/
|
||||
.server/
|
||||
.vscode/
|
||||
.history/
|
||||
|
||||
# test related
|
||||
*.fingerprint
|
||||
/test/results
|
||||
coverage.txt
|
||||
*.log
|
||||
|
||||
# probable archives
|
||||
.images
|
||||
*.tar
|
||||
*.jar
|
||||
*.war
|
||||
@@ -42,7 +13,12 @@ coverage.txt
|
||||
*.jpi
|
||||
*.hpi
|
||||
*.zip
|
||||
*.iml
|
||||
.idea/
|
||||
*.log
|
||||
.images
|
||||
.tmp/
|
||||
coverage.txt
|
||||
bin/
|
||||
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
@@ -60,13 +36,4 @@ coverage.txt
|
||||
# macOS Finder metadata
|
||||
.DS_STORE
|
||||
|
||||
*.profile
|
||||
|
||||
# attestation
|
||||
cosign.key
|
||||
cosign.pub
|
||||
|
||||
# Byte-compiled object files for python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.profile
|
||||
+22
-62
@@ -1,101 +1,61 @@
|
||||
version: "2"
|
||||
run:
|
||||
tests: false
|
||||
# TODO: enable this when we have coverage on docstring comments
|
||||
#issues:
|
||||
# # The list of ids of default excludes to include or disable.
|
||||
# include:
|
||||
# - EXC0002 # disable excluding of issues about comments from golint
|
||||
|
||||
linters:
|
||||
default: none
|
||||
# inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint
|
||||
disable-all: true
|
||||
enable:
|
||||
- asciicheck
|
||||
- bodyclose
|
||||
- copyloopvar
|
||||
- deadcode
|
||||
- depguard
|
||||
- dogsled
|
||||
- dupl
|
||||
- errcheck
|
||||
- exportloopref
|
||||
- funlen
|
||||
- gocognit
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- gofmt
|
||||
- goimports
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nakedret
|
||||
- nolintlint
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- staticcheck
|
||||
- structcheck
|
||||
- stylecheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
- varcheck
|
||||
- whitespace
|
||||
settings:
|
||||
funlen:
|
||||
lines: 70
|
||||
statements: 50
|
||||
gocritic:
|
||||
enabled-checks:
|
||||
- deferInLoop
|
||||
- ruleguard
|
||||
settings:
|
||||
ruleguard:
|
||||
rules: test/rules/rules.go
|
||||
gosec:
|
||||
excludes:
|
||||
- G115
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
# do not enable...
|
||||
# - deadcode # The owner seems to have abandoned the linter. Replaced by "unused".
|
||||
# - depguard # We don't have a configuration for this yet
|
||||
# - goprintffuncname # does not catch all cases and there are exceptions
|
||||
# - nakedret # does not catch all cases and should not fail a build
|
||||
# - gochecknoglobals
|
||||
# - gochecknoinits # this is too aggressive
|
||||
# - rowserrcheck disabled per generics https://github.com/golangci/golangci-lint/issues/2649
|
||||
# - godot
|
||||
# - godox
|
||||
# - goerr113
|
||||
# - goimports # we're using gosimports now instead to account for extra whitespaces (see https://github.com/golang/go/issues/20818)
|
||||
# - golint # deprecated
|
||||
# - gomnd # this is too aggressive
|
||||
# - interfacer # this is a good idea, but is no longer supported and is prone to false positives
|
||||
# - lll # without a way to specify per-line exception cases, this is not usable
|
||||
# - maligned # this is an excellent linter, but tricky to optimize and we are not sensitive to memory layout optimizations
|
||||
# - nestif
|
||||
# - prealloc # following this rule isn't consistently a good idea, as it sometimes forces unnecessary allocations that result in less idiomatic code
|
||||
# - rowserrcheck # not in a repo with sql, so this is not useful
|
||||
# - scopelint # deprecated
|
||||
# - structcheck # The owner seems to have abandoned the linter. Replaced by "unused".
|
||||
# - prealloc # following this rule isn't consistently a good idea, as it sometimes forces unnecessary allocations that result in less idiomatic code
|
||||
# - scopelint # deprecated
|
||||
# - testpackage
|
||||
# - varcheck # The owner seems to have abandoned the linter. Replaced by "unused".
|
||||
# - wsl # this doens't have an auto-fixer yet and is pretty noisy (https://github.com/bombsimon/wsl/issues/90)
|
||||
|
||||
issues:
|
||||
max-same-issues: 25
|
||||
uniq-by-line: false
|
||||
|
||||
# TODO: enable this when we have coverage on docstring comments
|
||||
# # The list of ids of default excludes to include or disable.
|
||||
# include:
|
||||
# - EXC0002 # disable excluding of issues about comments from golint
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
# - wsl # this doens't have an auto-fixer yet and is pretty noisy (https://github.com/bombsimon/wsl/issues/90)
|
||||
|
||||
+45
-176
@@ -1,38 +1,37 @@
|
||||
version: 2
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
draft: false
|
||||
draft: true
|
||||
|
||||
env:
|
||||
# required to support multi architecture docker builds
|
||||
- DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
- CGO_ENABLED=0
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- ./.github/scripts/apple-signing/setup.sh {{ .IsSnapshot }}
|
||||
|
||||
builds:
|
||||
- id: linux-build
|
||||
dir: ./cmd/syft
|
||||
binary: syft
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- ppc64le
|
||||
- s390x
|
||||
# set the modified timestamp on the output binary to the git timestamp to ensure a reproducible build
|
||||
mod_timestamp: &build-timestamp '{{ .CommitTimestamp }}'
|
||||
env: &build-env
|
||||
- CGO_ENABLED=0
|
||||
ldflags: &build-ldflags |
|
||||
-w
|
||||
-s
|
||||
-extldflags '-static'
|
||||
-X main.version={{.Version}}
|
||||
-X main.gitCommit={{.Commit}}
|
||||
-X main.buildDate={{.Date}}
|
||||
-X main.gitDescription={{.Summary}}
|
||||
-X github.com/anchore/syft/internal/version.version={{.Version}}
|
||||
-X github.com/anchore/syft/internal/version.gitCommit={{.Commit}}
|
||||
-X github.com/anchore/syft/internal/version.buildDate={{.Date}}
|
||||
-X github.com/anchore/syft/internal/version.gitDescription={{.Summary}}
|
||||
|
||||
- id: darwin-build
|
||||
dir: ./cmd/syft
|
||||
binary: syft
|
||||
goos:
|
||||
- darwin
|
||||
@@ -40,21 +39,17 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
mod_timestamp: *build-timestamp
|
||||
env: *build-env
|
||||
ldflags: *build-ldflags
|
||||
hooks:
|
||||
post:
|
||||
- cmd: .tool/quill sign-and-notarize "{{ .Path }}" --dry-run={{ .IsSnapshot }} --ad-hoc={{ .IsSnapshot }} -vv
|
||||
env:
|
||||
- QUILL_LOG_FILE=/tmp/quill-{{ .Target }}.log
|
||||
|
||||
- id: windows-build
|
||||
dir: ./cmd/syft
|
||||
binary: syft
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
mod_timestamp: *build-timestamp
|
||||
env: *build-env
|
||||
ldflags: *build-ldflags
|
||||
|
||||
archives:
|
||||
@@ -72,6 +67,16 @@ archives:
|
||||
builds:
|
||||
- windows-build
|
||||
|
||||
signs:
|
||||
- artifacts: archive
|
||||
ids:
|
||||
- darwin-archives
|
||||
signature: "${artifact}"
|
||||
cmd: ./.github/scripts/apple-signing/sign.sh
|
||||
args:
|
||||
- "${artifact}"
|
||||
- "{{ .IsSnapshot }}"
|
||||
|
||||
nfpms:
|
||||
- license: "Apache 2.0"
|
||||
maintainer: "Anchore, Inc"
|
||||
@@ -82,10 +87,9 @@ nfpms:
|
||||
- deb
|
||||
|
||||
brews:
|
||||
- repository:
|
||||
- tap:
|
||||
owner: anchore
|
||||
name: homebrew-syft
|
||||
token: "{{.Env.GITHUB_BREW_TOKEN}}"
|
||||
ids:
|
||||
- darwin-archives
|
||||
- linux-archives
|
||||
@@ -95,71 +99,10 @@ brews:
|
||||
|
||||
dockers:
|
||||
- image_templates:
|
||||
- anchore/syft:debug
|
||||
- anchore/syft:{{.Tag}}-debug
|
||||
- ghcr.io/anchore/syft:debug
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug
|
||||
goarch: amd64
|
||||
dockerfile: Dockerfile.debug
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:debug-arm64v8
|
||||
- anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
- ghcr.io/anchore/syft:debug-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
goarch: arm64
|
||||
dockerfile: Dockerfile.debug
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64/v8"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:debug-ppc64le
|
||||
- anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
- ghcr.io/anchore/syft:debug-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
goarch: ppc64le
|
||||
dockerfile: Dockerfile.debug
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/ppc64le"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:debug-s390x
|
||||
- anchore/syft:{{.Tag}}-debug-s390x
|
||||
- ghcr.io/anchore/syft:debug-s390x
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-s390x
|
||||
goarch: s390x
|
||||
dockerfile: Dockerfile.debug
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/s390x"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:latest
|
||||
- anchore/syft:{{.Tag}}
|
||||
- ghcr.io/anchore/syft:latest
|
||||
- ghcr.io/anchore/syft:{{.Tag}}
|
||||
goarch: amd64
|
||||
- "anchore/syft:latest"
|
||||
- "anchore/syft:{{ .Tag }}-amd64"
|
||||
- "anchore/syft:v{{ .Major }}-amd64"
|
||||
- "anchore/syft:v{{ .Major }}.{{ .Minor }}-amd64"
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
@@ -170,8 +113,9 @@ dockers:
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-arm64v8
|
||||
- "anchore/syft:{{ .Tag }}-arm64v8"
|
||||
- "anchore/syft:v{{ .Major }}-arm64v8"
|
||||
- "anchore/syft:v{{ .Major }}.{{ .Minor }}-arm64v8"
|
||||
goarch: arm64
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
@@ -182,95 +126,20 @@ dockers:
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-ppc64le
|
||||
goarch: ppc64le
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/ppc64le"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-s390x
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-s390x
|
||||
goarch: s390x
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/s390x"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
docker_manifests:
|
||||
- name_template: anchore/syft:{{ .Tag }}
|
||||
image_templates:
|
||||
- anchore/syft:{{ .Tag }}-amd64
|
||||
- anchore/syft:v{{ .Major }}-amd64
|
||||
- anchore/syft:v{{ .Major }}.{{ .Minor }}-amd64
|
||||
- anchore/syft:{{ .Tag }}-arm64v8
|
||||
- anchore/syft:v{{ .Major }}-arm64v8
|
||||
- anchore/syft:v{{ .Major }}.{{ .Minor }}-arm64v8
|
||||
- name_template: anchore/syft:latest
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}
|
||||
- anchore/syft:{{.Tag}}-arm64v8
|
||||
- anchore/syft:{{.Tag}}-ppc64le
|
||||
- anchore/syft:{{.Tag}}-s390x
|
||||
|
||||
- name_template: anchore/syft:debug
|
||||
- anchore/syft:{{.Tag}}-debug
|
||||
- anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
- anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
- anchore/syft:{{.Tag}}-debug-s390x
|
||||
|
||||
- name_template: anchore/syft:{{.Tag}}
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}
|
||||
- anchore/syft:{{.Tag}}-arm64v8
|
||||
- anchore/syft:{{.Tag}}-ppc64le
|
||||
- anchore/syft:{{.Tag}}-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:latest
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:debug
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:{{.Tag}}
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-s390x
|
||||
|
||||
sboms:
|
||||
- artifacts: archive
|
||||
# this is relative to the snapshot/dist directory, not the root of the repo
|
||||
cmd: ../.tool/syft
|
||||
documents:
|
||||
- "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.sbom"
|
||||
args:
|
||||
- "scan"
|
||||
- "$artifact"
|
||||
- "--output"
|
||||
- "json=$document"
|
||||
|
||||
signs:
|
||||
- cmd: .tool/cosign
|
||||
signature: "${artifact}.sig"
|
||||
certificate: "${artifact}.pem"
|
||||
args:
|
||||
- "sign-blob"
|
||||
- "--oidc-issuer=https://token.actions.githubusercontent.com"
|
||||
- "--output-certificate=${certificate}"
|
||||
- "--output-signature=${signature}"
|
||||
- "${artifact}"
|
||||
- "--yes"
|
||||
artifacts: checksum
|
||||
- anchore/syft:{{ .Tag }}-amd64
|
||||
- anchore/syft:v{{ .Major }}-amd64
|
||||
- anchore/syft:v{{ .Major }}.{{ .Minor }}-amd64
|
||||
- anchore/syft:{{ .Tag }}-arm64v8
|
||||
- anchore/syft:v{{ .Major }}-arm64v8
|
||||
- anchore/syft:v{{ .Major }}.{{ .Minor }}-arm64v8
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
[opensource@anchore.com](mailto:opensource@anchore.com).
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
+28
-74
@@ -1,58 +1,7 @@
|
||||
[#](#) Contributing to Syft
|
||||
# Contributing to Syft
|
||||
|
||||
If you are looking to contribute to this project and want to open a GitHub pull request ("PR"), there are a few guidelines of what we are looking for in patches. Make sure you go through this document and ensure that your code proposal is aligned.
|
||||
|
||||
## Setting up your environment
|
||||
|
||||
Before you can contribute to Syft, you need to configure your development environment.
|
||||
|
||||
### Debian setup
|
||||
|
||||
You will need to install Go. The version on https://go.dev works best, using the system golang doesn't always work the way you might expect.
|
||||
|
||||
Refer to the go.mod file in the root of this repo for the recommended version of Go to install.
|
||||
|
||||
You will also need Docker. There's no reason the system packages shouldn't work, but we used the official Docker package. You can find instructions for installing Docker in Debian [here](https://docs.docker.com/engine/install/debian/).
|
||||
|
||||
You also need to install some Debian packages
|
||||
|
||||
```sh
|
||||
sudo apt-get install build-essential zip bc libxml2-utils git
|
||||
```
|
||||
|
||||
## Configuring Git
|
||||
|
||||
You will need to configure your git client with your name and email address. This is easily done from the command line.
|
||||
|
||||
```text
|
||||
$ git config --global user.name "John Doe"
|
||||
$ git config --global user.email "john.doe@example.com"
|
||||
```
|
||||
|
||||
This username and email address will matter later in this guide.
|
||||
|
||||
## Fork the repo
|
||||
|
||||
You should fork the Syft repo using the "Fork" button at the top right of the Syft GitHub [site](https://github.com/anchore/syft/). You will be doing your development in your fork, then submit a pull request to Syft. There are many resources how to use GitHub effectively, we will not cover those here.
|
||||
|
||||
## Adding a feature or fix
|
||||
|
||||
If you look at the Syft [Issue](https://github.com/anchore/syft/issues) there are plenty of bugs and feature requests. Maybe look at the [good first issue](https://github.com/anchore/syft/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) list if you're not sure where to start.
|
||||
|
||||
## Commit guidelines
|
||||
|
||||
In the Syft project we like commits and pull requests (PR) to be easy to understand and review. Open source thrives best when everything happening is over documented and small enough to be understood.
|
||||
|
||||
### Granular commits
|
||||
|
||||
Please try to make every commit as simple as possible, but no simpler. The idea is that each commit should be a logical unit of code. Try not to commit too many tiny changes, for example every line changed in a file as a separate commit. And also try not to make a commit enormous, for example committing all your work at the end of the day.
|
||||
|
||||
Rather than try to follow a strict guide on what is or is not best, we try to be flexible and simple in this space. Do what makes the most sense for the changes you are trying to include.
|
||||
|
||||
### Commit title and description
|
||||
|
||||
Remember that the message you leave for a commit is for the reviewer in the present, and for someone (maybe you) changing something in the future. Please make sure the title and description used is easy to understand and explains what was done. Jokes and clever comments generally don't age well in commit messages. Just the facts please.
|
||||
|
||||
## Sign off your work
|
||||
|
||||
The `sign-off` is an added line at the end of the explanation for the commit, certifying that you wrote it or otherwise have the right to submit it as an open-source patch. By submitting a contribution, you agree to be bound by the terms of the DCO Version 1.1 and Apache License Version 2.0.
|
||||
@@ -95,6 +44,13 @@ When committing your change, you can add the required line manually so that it l
|
||||
Signed-off-by: John Doe <john.doe@example.com>
|
||||
```
|
||||
|
||||
Alternatively, configure your Git client with your name and email to use the `-s` flag when creating a commit:
|
||||
|
||||
```text
|
||||
$ git config --global user.name "John Doe"
|
||||
$ git config --global user.email "john.doe@example.com"
|
||||
```
|
||||
|
||||
Creating a signed-off commit is then possible with `-s` or `--signoff`:
|
||||
|
||||
```text
|
||||
@@ -114,9 +70,27 @@ Date: Mon Aug 1 11:27:13 2020 -0400
|
||||
Signed-off-by: John Doe <john.doe@example.com>
|
||||
```
|
||||
|
||||
|
||||
[//]: # (TODO: Commit guidelines, granular commits)
|
||||
|
||||
|
||||
[//]: # (TODO: Commit guidelines, descriptive messages)
|
||||
|
||||
|
||||
[//]: # (TODO: Commit guidelines, commit title, extra body description)
|
||||
|
||||
|
||||
[//]: # (TODO: PR title and description)
|
||||
|
||||
## Sign your commits
|
||||
|
||||
To ensure the authenticity and integrity of code contributions, **we require that all commits are signed**. Signing commits proves that your commits were truly created by you, as the holder of a private key.
|
||||
|
||||
Configuring git to sign your commits is a straightforward process. To get set up, see [GitHub's documentation on signing commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits).
|
||||
|
||||
## Test your changes
|
||||
|
||||
This project has a `Makefile` which includes many helpers running both unit and integration tests. You can run `make help` to see all the options. Although PRs will have automatic checks for these, it is useful to run them locally, ensuring they pass before submitting changes. Ensure you've bootstrapped once before running tests:
|
||||
This project has a `Makefile` which includes many helpers running both unit and integration tests. Although PRs will have automatic checks for these, it is useful to run them locally, ensuring they pass before submitting changes. Ensure you've bootstrapped once before running tests:
|
||||
|
||||
```text
|
||||
$ make bootstrap
|
||||
@@ -129,26 +103,6 @@ $ make unit
|
||||
$ make integration
|
||||
```
|
||||
|
||||
You can also run `make all` to run a more extensive test suite, but there is additional configuration that will be needed for those tests to run correctly. We will not cover the extra steps here.
|
||||
|
||||
## Pull Request
|
||||
|
||||
If you made it this far and all the tests are passing, it's time to submit a Pull Request (PR) for Syft. Submitting a PR is always a scary moment as what happens next can be an unknown. The Syft project strives to be easy to work with, we appreciate all contributions. Nobody is going to yell at you or try to make you feel bad. We love contributions and know how scary that first PR can be.
|
||||
|
||||
### PR Title and Description
|
||||
|
||||
Just like the commit title and description mentioned above, the PR title and description is very important for letting others know what's happening. Please include any details you think a reviewer will need to more properly review your PR.
|
||||
|
||||
A PR that is very large or poorly described has a higher likelihood of being pushed to the end of the list. Reviewers like PRs they can understand and quickly review.
|
||||
|
||||
### What to expect next
|
||||
|
||||
Please be patient with the project. We try to review PRs in a timely manner, but this is highly dependent on all the other tasks we have going on. It's OK to ask for a status update every week or two, it's not OK to ask for a status update every day.
|
||||
|
||||
It's very likely the reviewer will have questions and suggestions for changes to your PR. If your changes don't match the current style and flow of the other code, expect a request to change what you've done.
|
||||
|
||||
## Document your changes
|
||||
|
||||
And lastly, when proposed changes are modifying user-facing functionality or output, it is expected the PR will include updates to the documentation as well. Syft is not a project that is heavy on documentation. This will mostly be updating the README and help for the tool.
|
||||
|
||||
If nobody knows new features exist, they can't use them!
|
||||
When proposed changes are modifying user-facing functionality or output, it is expected the PR will include updates to the documentation as well.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Syft Contributors
|
||||
|
||||
The following Syft components were contributed by external authors/organizations.
|
||||
|
||||
## GraalVM Native Image
|
||||
|
||||
A cataloger contributed by Oracle Corporation that extracts packages given within GraalVM Native Image SBOMs.
|
||||
|
||||
## Swift Package Manager
|
||||
|
||||
A cataloger contributed by Axis Communications that catalogs packages resolved by Swift Package Manager.
|
||||
|
||||
## Bitnami Packages
|
||||
|
||||
A cataloger contributed by Bitnami developer that catalogs packages described in Bitnami SBOMs.
|
||||
+20
-233
@@ -3,248 +3,25 @@
|
||||
## Getting started
|
||||
|
||||
In order to test and develop in this repo you will need the following dependencies installed:
|
||||
- Golang
|
||||
- docker
|
||||
- make
|
||||
- Python (>= 3.9)
|
||||
|
||||
### Docker settings for getting started
|
||||
Make sure you've updated your docker settings so the default docker socket path is available.
|
||||
|
||||
Go to:
|
||||
|
||||
docker -> settings -> advanced
|
||||
|
||||
Make sure:
|
||||
|
||||
```
|
||||
Allow the default Docker socket to be used
|
||||
```
|
||||
|
||||
is checked.
|
||||
|
||||
Also double check that the docker context being used is the default context. If it is not, run:
|
||||
|
||||
`docker context use default`
|
||||
|
||||
After cloning the following step can help you get setup:
|
||||
After cloning do the following:
|
||||
1. run `make bootstrap` to download go mod dependencies, create the `/.tmp` dir, and download helper utilities.
|
||||
2. run `make` to view the selection of developer commands in the Makefile
|
||||
3. run `make build` to build the release snapshot binaries and packages
|
||||
4. for an even quicker start you can run `go run cmd/syft/main.go` to print the syft help.
|
||||
- this command `go run cmd/syft/main.go alpine:latest` will compile and run syft against `alpine:latest`
|
||||
5. view the README or syft help output for more output options
|
||||
2. run `make` to run linting, tests, and other verifications to make certain everything is working alright.
|
||||
|
||||
The main make tasks for common static analysis and testing are `lint`, `format`, `lint-fix`, `unit`, `integration`, and `cli`.
|
||||
Checkout `make help` to see what other actions you can take.
|
||||
|
||||
See `make help` for all the current make tasks.
|
||||
The main make tasks for common static analysis and testing are `lint`, `lint-fix`, `unit`, `integration`, and `cli`.
|
||||
|
||||
### Internal Artifactory Settings
|
||||
|
||||
**Not always applicable**
|
||||
|
||||
Some companies have Artifactory setup internally as a solution for sourcing secure dependencies.
|
||||
If you're seeing an issue where the unit tests won't run because of the below error then this section might be relevant for your use case.
|
||||
|
||||
```
|
||||
[ERROR] [ERROR] Some problems were encountered while processing the POMs
|
||||
```
|
||||
|
||||
If you're dealing with an issue where the unit tests will not pull/build certain java fixtures check some of these settings:
|
||||
|
||||
- a `settings.xml` file should be available to help you communicate with your internal artifactory deployment
|
||||
- this can be moved to `syft/pkg/cataloger/java/test-fixtures/java-builds/example-jenkins-plugin/` to help build the unit test-fixtures
|
||||
- you'll also want to modify the `build-example-jenkins-plugin.sh` to use `settings.xml`
|
||||
|
||||
For more information on this setup and troubleshooting see [issue 1895](https://github.com/anchore/syft/issues/1895#issuecomment-1610085319)
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
At a high level, this is the package structure of syft:
|
||||
```
|
||||
./cmd/syft/
|
||||
│ ├── cli/
|
||||
│ │ ├── cli.go // where all commands are wired up
|
||||
│ │ ├── commands/ // all command implementations
|
||||
│ │ ├── options/ // all command flags and configuration options
|
||||
│ │ └── ui/ // all handlers for events that are shown on the UI
|
||||
│ └── main.go // entrypoint for the application
|
||||
└── syft/ // the "core" syft library
|
||||
├── format/ // contains code to encode or decode to and from SBOM formats
|
||||
├── pkg/ // contains code to catalog packages from a source
|
||||
├── sbom/ // contains the definition of an SBOM
|
||||
└── source/ // contains code to create a source object for some input type (e.g. container image, directory, etc)
|
||||
```
|
||||
|
||||
Syft's core library is implemented in the `syft` package and subpackages, where the major packages are:
|
||||
|
||||
- the `syft/source` package produces a `source.Source` object that can be used to catalog a directory, container, and other source types.
|
||||
- the `syft` package contains a single function that can take a `source.Source` object and catalog it, producing an `sbom.SBOM` object
|
||||
- the `syft/format` package contains the ability to encode and decode SBOMs to and from different SBOM formats (such as SPDX and CycloneDX)
|
||||
|
||||
The `cmd` package at the highest level execution flow wires up [`spf13/cobra`](https://github.com/spf13/cobra) commands for execution in the main application:
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant main as cmd/syft/main
|
||||
participant cli as cli.New()
|
||||
participant root as root.Execute()
|
||||
participant cmd as <command>.Execute()
|
||||
|
||||
main->>+cli:
|
||||
|
||||
Note right of cli: wire ALL CLI commands
|
||||
Note right of cli: add flags for ALL commands
|
||||
|
||||
cli-->>-main: root command
|
||||
|
||||
main->>+root:
|
||||
root->>+cmd:
|
||||
cmd-->>-root: (error)
|
||||
|
||||
root-->>-main: (error)
|
||||
|
||||
Note right of cmd: Execute SINGLE command from USER
|
||||
```
|
||||
|
||||
The `packages` command uses the core library to generate an SBOM for the given user input:
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant source as source.New(ubuntu:latest)
|
||||
participant sbom as sbom.SBOM
|
||||
participant catalog as syft.CatalogPackages(src)
|
||||
participant encoder as syft.Encode(sbom, format)
|
||||
|
||||
Note right of source: use "ubuntu:latest" as SBOM input
|
||||
|
||||
source-->>+sbom: add source to SBOM struct
|
||||
source-->>+catalog: pass src to generate catalog
|
||||
catalog-->-sbom: add cataloging results onto SBOM
|
||||
sbom-->>encoder: pass SBOM and format desiered to syft encoder
|
||||
encoder-->>source: return bytes that are the SBOM of the original input
|
||||
|
||||
Note right of catalog: cataloger configuration is done based on src
|
||||
```
|
||||
|
||||
Additionally, here is a [gist of using syft as a library](https://gist.github.com/spiffcs/3027638b7ba904d07e482a712bc00d3d) to generate a SBOM for a docker image.
|
||||
|
||||
|
||||
### `pkg.Package` object
|
||||
|
||||
The `pkg.Package` object is a core data structure that represents a software package. Fields like `name` and `version` probably don't need
|
||||
a detailed explanation, but some of the other fields are worth a quick overview:
|
||||
|
||||
- `FoundBy`: the name of the cataloger that discovered this package (e.g. `python-pip-cataloger`).
|
||||
- `Locations`: these are the set of paths and layer ids that were parsed to discover this package (e.g. `python-pip-cataloger`).
|
||||
- `Language`: the language of the package (e.g. `python`).
|
||||
- `Type`: this is a high-level categorization of the ecosystem the package resides in. For instance, even if the package is a egg, wheel, or requirements.txt reference, it is still logically a "python" package. Not all package types align with a language (e.g. `rpm`) but it is common.
|
||||
- `Metadata`: specialized data for specific location(s) parsed. We should try and raise up as much raw information that seems useful. As a rule of thumb the object here should be as flat as possible and use the raw names and values from the underlying source material parsed.
|
||||
|
||||
When `pkg.Package` is serialized an additional `MetadataType` is shown. This is a label that helps consumers understand the datashape of the `Metadata` field.
|
||||
|
||||
By convention the `MetadataType` value should follow these rules of thumb:
|
||||
|
||||
- Only use lowercase letters, numbers, and hyphens. Use hyphens to separate words.
|
||||
- **Try to anchor the name in the ecosystem, language, or packaging tooling it belongs to**. For a package manager for a language ecosystem the language, framework or runtime should be used as a prefix. For instance `pubspec-lock` is an OK name, but `dart-pubspec-lock` is better. For an OS package manager this is not necessary (e.g. `apk-db-entry` is a good name, but `alpine-apk-db-entry` is not since `alpine` and the `a` in `apk` is redundant).
|
||||
- **Be as specific as possible to what the data represents**. For instance `ruby-gem` is NOT a good `MetadataType` value, but `ruby-gemspec` is. Why? Ruby gem information can come from a gemspec file or a Gemfile.lock, which are very different. The latter name provides more context as to what to expect.
|
||||
- **Should describe WHAT the data is, NOT HOW it's used**. For instance `r-description-installed-file` is NOT a good `MetadataType` value since it's trying to convey that we use the DESCRIPTION file in the R ecosystem to detect installed packages. Instead simply describe what the DESCRIPTION file is itself without context of how it's used: `r-description`.
|
||||
- **Use the `lock` suffix** to distinct between manifest files that loosely describe package version requirements vs files that strongly specify one and only one version of a package ("lock" files). These should only be used with respect to package managers that have the guide and lock distinction, but would not be appropriate otherwise (e.g. `rpm` does not have a guide vs lock, so `lock` should NOT be used to describe a db entry).
|
||||
- **Use the `archive` suffix to indicate a package archive** (e.g. rpm file, apk file, etc) that describes the contents of the package. For example an RPM file that was cataloged would have a `rpm-archive` metadata type (not to be confused with an RPM DB record entry which would be `rpm-db-entry`).
|
||||
- **Use the `entry` suffix** to indicate information about a package that was found as a single entry within file that has multiple package entries. If the entry was found within a DB or a flat-file store for an OS package manager, you should use `db-entry`.
|
||||
- **Should NOT contain the phrase `package`**, though exceptions are allowed (say if the canonical name literally has the phrase package in it).
|
||||
- **Should NOT contain have a `file` suffix** unless the canonical name has the term "file", such as a `pipfile` or `gemfile`. An example of a bad name for this rule is`ruby-gemspec-file`; a better name would be `ruby-gemspec`.
|
||||
- **Should NOT contain the exact filename+extensions**. For instance `pipfile.lock` shouldn't really be in the name, instead try and describe what the file is: `python-pipfile-lock` (but shouldn't this be `python-pip-lock` you might ask? No, since the `pip` package manger is not related to the `pipfile` project).
|
||||
- **Should NOT contain the phrase `metadata`**, unless the canonical name has this term.
|
||||
- **Should represent a single use case**. For example, trying to describe Hackage metadata with a single `HackageMetadata` struct (and thus `MetadataType`) is not allowed since it represents 3 mutually exclusive use cases: representing a `stack.yaml`, `stack.lock`, or `cabal.project` file. Instead, each of these should have their own struct types and `MetadataType` values.
|
||||
|
||||
There are other cases that are not covered by these rules... and that's ok! The goal is to provide a consistent naming scheme that is easy to understand and use when it's applicable. If the rules do not exactly apply in your situation then just use your best judgement (or amend these rules as needed whe new common cases come up).
|
||||
|
||||
What if the underlying parsed data represents multiple files? There are two approaches to this:
|
||||
- use the primary file to represent all the data. For instance, though the `dpkg-cataloger` looks at multiple files to get all information about a package, it's the `status` file that gets represented.
|
||||
- nest each individual file's data under the `Metadata` field. For instance, the `java-archive-cataloger` may find information from on or all of the files: `pom.xml`, `pom.properties`, and `MANIFEST.MF`. However, the metadata is simply `java-metadata' with each possibility as a nested optional field.
|
||||
|
||||
### Syft Catalogers
|
||||
|
||||
Catalogers are the way in which syft is able to identify and construct packages given a set a targeted list of files.
|
||||
For example, a cataloger can ask syft for all `package-lock.json` files in order to parse and raise up javascript packages
|
||||
(see [how file globs](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/javascript/cataloger.go#L16-L21) and
|
||||
[file parser functions](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/javascript/cataloger.go#L16-L21) are used
|
||||
for a quick example).
|
||||
|
||||
From a high level catalogers have the following properties:
|
||||
|
||||
- _They are independent from one another_. The java cataloger has no idea of the processes, assumptions, or results of the python cataloger, for example.
|
||||
|
||||
- _They do not know what source is being analyzed_. Are we analyzing a local directory? an image? if so, the squashed representation or all layers? The catalogers do not know the answers to these questions. Only that there is an interface to query for file paths and contents from an underlying "source" being scanned.
|
||||
|
||||
- _Packages created by the cataloger should not be mutated after they are created_. There is one exception made for adding CPEs to a package after the cataloging phase, but that will most likely be moved back into the cataloger in the future.
|
||||
|
||||
|
||||
Cataloger names should be unique and named with the following rules of thumb in mind:
|
||||
|
||||
- Must end with `-cataloger`
|
||||
- Use lowercase letters, numbers, and hyphens only
|
||||
- Use hyphens to separate words
|
||||
- Catalogers for language ecosystems should start with the language name (e.g. `python-` for a cataloger that raises up python packages)
|
||||
- Distinct between when the cataloger is searching for evidence of installed packages vs declared packages. For example, there are currently two different gemspec-based catalogers, the `ruby-gemspec-cataloger` and `ruby-installed-gemspec-cataloger`, where the latter requires that the gemspec is found within a `specifications` directory (which means it was installed, not just at the root of a source repo).
|
||||
|
||||
#### Building a new Cataloger
|
||||
|
||||
Catalogers must fulfill the [`pkg.Cataloger` interface](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger.go) in order to add packages to the SBOM.
|
||||
All catalogers should be added to:
|
||||
- the [global list of catalogers](https://github.com/anchore/syft/blob/9995950c70e849f9921919faffbfcf46401f71f3/syft/pkg/cataloger/cataloger.go#L92-L125)
|
||||
- at least one source-specific list, today the two lists are [directory catalogers and image catalogers](https://github.com/anchore/syft/blob/9995950c70e849f9921919faffbfcf46401f71f3/syft/pkg/cataloger/cataloger.go#L39-L89)
|
||||
|
||||
For reference, catalogers are [invoked within syft](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/catalog.go#L41-L100) one after the other, and can be invoked in parallel.
|
||||
|
||||
`generic.NewCataloger` is an abstraction syft used to make writing common components easier (see the [apkdb cataloger](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/apkdb/cataloger.go) for example usage).
|
||||
It takes the following information as input:
|
||||
- A `catalogerName` to identify the cataloger uniquely among all other catalogers.
|
||||
- Pairs of file globs as well as parser functions to parse those files. These parser functions return a slice of [`pkg.Package`](https://github.com/anchore/syft/blob/9995950c70e849f9921919faffbfcf46401f71f3/syft/pkg/package.go#L19) as well as a slice of [`artifact.Relationship`](https://github.com/anchore/syft/blob/9995950c70e849f9921919faffbfcf46401f71f3/syft/artifact/relationship.go#L31) to describe how the returned packages are related. See this [the apkdb cataloger parser function](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/apkdb/parse_apk_db.go#L22-L102) as an example.
|
||||
|
||||
Identified packages share a common `pkg.Package` struct so be sure that when the new cataloger is constructing a new package it is using the [`Package` struct](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/package.go#L16-L31).
|
||||
If you want to return more information than what is available on the `pkg.Package` struct then you can do so in the `pkg.Package.Metadata` section of the struct, which is unique for each [`pkg.Type`](https://github.com/anchore/syft/blob/v0.70.0/syft/pkg/type.go).
|
||||
See [the `pkg` package](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg) for examples of the different metadata types that are supported today.
|
||||
These are plugged into the `MetadataType` and `Metadata` fields in the above struct. `MetadataType` informs which type is being used. `Metadata` is an interface converted to that type.
|
||||
|
||||
Finally, here is an example of where the package construction is done within the apk cataloger:
|
||||
- [Calling the APK package constructor from the parser function](https://github.com/anchore/syft/blob/v0.70.0/syft/pkg/cataloger/apkdb/parse_apk_db.go#L106)
|
||||
- [The APK package constructor itself](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/apkdb/package.go#L12-L27)
|
||||
|
||||
Interested in building a new cataloger? Checkout the [list of issues with the `new-cataloger` label](https://github.com/anchore/syft/issues?q=is%3Aopen+is%3Aissue+label%3Anew-cataloger+no%3Aassignee)!
|
||||
If you have questions about implementing a cataloger feel free to file an issue or reach out to us [on discourse](https://anchore.com/discourse)!
|
||||
|
||||
|
||||
#### Searching for files
|
||||
|
||||
All catalogers are provided an instance of the [`file.Resolver`](https://github.com/anchore/syft/blob/v0.70.0/syft/source/file_resolver.go#L8) to interface with the image and search for files. The implementations for these
|
||||
abstractions leverage [`stereoscope`](https://github.com/anchore/stereoscope) in order to perform searching. Here is a
|
||||
rough outline how that works:
|
||||
|
||||
1. a stereoscope `file.Index` is searched based on the input given (a path, glob, or MIME type). The index is relatively fast to search, but requires results to be filtered down to the files that exist in the specific layer(s) of interest. This is done automatically by the `filetree.Searcher` abstraction. This abstraction will fallback to searching directly against the raw `filetree.FileTree` if the index does not contain the file(s) of interest. Note: the `filetree.Searcher` is used by the `file.Resolver` abstraction.
|
||||
2. Once the set of files are returned from the `filetree.Searcher` the results are filtered down further to return the most unique file results. For example, you may have requested for files by a glob that returns multiple results. These results are filtered down to deduplicate by real files, so if a result contains two references to the same file, say one accessed via symlink and one accessed via the real path, then the real path reference is returned and the symlink reference is filtered out. If both were accessed by symlink then the first (by lexical order) is returned. This is done automatically by the `file.Resolver` abstraction.
|
||||
3. By the time results reach the `pkg.Cataloger` you are guaranteed to have a set of unique files that exist in the layer(s) of interest (relative to what the resolver supports).
|
||||
|
||||
## Testing
|
||||
|
||||
### Testing commands
|
||||
|
||||
* `make help` shows a list of available commands
|
||||
* `make unit`, `make integration`, `make cli`, and `make acceptance` run those test suites (see below)
|
||||
* `make test` runs all those tests (and is therefore pretty slow)
|
||||
* `make fixtures` clears and re-fetches all test fixtures.
|
||||
* `go test ./syft/pkg/` for example can test particular packages, assuming fixtures are already made
|
||||
* `make clean-cache` cleans all test cache. Note that subsequent test runs will be slower after this
|
||||
|
||||
|
||||
### Levels of testing
|
||||
## Levels of testing
|
||||
|
||||
- `unit`: The default level of test which is distributed throughout the repo are unit tests. Any `_test.go` file that
|
||||
does not reside somewhere within the `/test` directory is a unit test. Other forms of testing should be organized in
|
||||
the `/test` directory. These tests should focus on correctness of functionality in depth. % test coverage metrics
|
||||
the `/test` directory. These tests should focus on correctness of functionality in depth. % Test coverage metrics
|
||||
only considers unit tests and no other forms of testing.
|
||||
|
||||
- `integration`: located within `cmd/syft/internal/test/integration`, these tests focus on the behavior surfaced by the common library
|
||||
- `integration`: located within `test/integration`, these tests focus on the behavior surfaced by the common library
|
||||
entrypoints from the `syft` package and make light assertions about the results surfaced. Additionally, these tests
|
||||
tend to make diversity assertions for enum-like objects, ensuring that as enum values are added to a definition
|
||||
that integration tests will automatically fail if no test attempts to use that enum value. For more details see
|
||||
@@ -377,11 +154,11 @@ package under test and should always be updated by invoking `go test` on the spe
|
||||
update flag provided.
|
||||
|
||||
Many of the `Format` tests make use of this approach, where the raw SBOM report is saved in the repo and the test
|
||||
compares that SBOM with what is generated from the latest presenter code. The following command can be used to
|
||||
update the golden files for the various snapshot tests:
|
||||
compares that SBOM with what is generated from the latest presenter code. For instance, at the time of this writing
|
||||
the CycloneDX presenter snapshots can be updated by running:
|
||||
|
||||
```bash
|
||||
make update-format-golden-files
|
||||
go test ./internal/formats -update-cyclonedx
|
||||
```
|
||||
|
||||
These flags are defined at the top of the test files that have tests that use the snapshot files.
|
||||
@@ -389,4 +166,14 @@ These flags are defined at the top of the test files that have tests that use th
|
||||
Snapshot testing is only as good as the manual verification of the golden snapshot file saved to the repo! Be careful
|
||||
and diligent when updating these files.
|
||||
|
||||
## Architecture
|
||||
|
||||
TODO: outline:
|
||||
- analysis creates a static SBOM which can be encoded and decoded.
|
||||
- format objects, should strive to not add or enrich data in encoding that could otherwise be done during analysis
|
||||
- pkg.Catalogers
|
||||
- file catalogers
|
||||
- source.Source
|
||||
- file.Resolvers
|
||||
- logger abstraction
|
||||
- events / bus abstraction
|
||||
+4
-4
@@ -1,4 +1,6 @@
|
||||
FROM gcr.io/distroless/static-debian12:debug@sha256:5c474275684bbf271ec40502ab50158b2f9826de5877d8feec27e22e8d6ee3d2 AS build
|
||||
FROM alpine:latest AS build
|
||||
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
FROM scratch
|
||||
# needed for version check HTTPS request
|
||||
@@ -22,8 +24,6 @@ LABEL org.opencontainers.image.revision=$VCS_REF
|
||||
LABEL org.opencontainers.image.vendor="Anchore, Inc."
|
||||
LABEL org.opencontainers.image.version=$BUILD_VERSION
|
||||
LABEL org.opencontainers.image.licenses="Apache-2.0"
|
||||
LABEL io.artifacthub.package.readme-url="https://raw.githubusercontent.com/anchore/syft/main/README.md"
|
||||
LABEL io.artifacthub.package.logo-url="https://user-images.githubusercontent.com/5199289/136844524-1527b09f-c5cb-4aa9-be54-5aa92a6086c1.png"
|
||||
LABEL io.artifacthub.package.license="Apache-2.0"
|
||||
|
||||
|
||||
ENTRYPOINT ["/syft"]
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
FROM gcr.io/distroless/static-debian12:debug@sha256:5c474275684bbf271ec40502ab50158b2f9826de5877d8feec27e22e8d6ee3d2
|
||||
|
||||
# create the /tmp dir, which is needed for image content cache
|
||||
WORKDIR /tmp
|
||||
|
||||
COPY syft /
|
||||
|
||||
ARG BUILD_DATE
|
||||
ARG BUILD_VERSION
|
||||
ARG VCS_REF
|
||||
ARG VCS_URL
|
||||
|
||||
LABEL org.opencontainers.image.created=$BUILD_DATE
|
||||
LABEL org.opencontainers.image.title="syft"
|
||||
LABEL org.opencontainers.image.description="CLI tool and library for generating a Software Bill of Materials from container images and filesystems"
|
||||
LABEL org.opencontainers.image.source=$VCS_URL
|
||||
LABEL org.opencontainers.image.revision=$VCS_REF
|
||||
LABEL org.opencontainers.image.vendor="Anchore, Inc."
|
||||
LABEL org.opencontainers.image.version=$BUILD_VERSION
|
||||
LABEL org.opencontainers.image.licenses="Apache-2.0"
|
||||
LABEL io.artifacthub.package.readme-url="https://raw.githubusercontent.com/anchore/syft/main/README.md"
|
||||
LABEL io.artifacthub.package.logo-url="https://user-images.githubusercontent.com/5199289/136844524-1527b09f-c5cb-4aa9-be54-5aa92a6086c1.png"
|
||||
LABEL io.artifacthub.package.license="Apache-2.0"
|
||||
|
||||
ENTRYPOINT ["/syft"]
|
||||
@@ -1,46 +1,370 @@
|
||||
OWNER = anchore
|
||||
PROJECT = syft
|
||||
BIN = syft
|
||||
TEMPDIR = ./.tmp
|
||||
RESULTSDIR = test/results
|
||||
COVER_REPORT = $(RESULTSDIR)/unit-coverage-details.txt
|
||||
COVER_TOTAL = $(RESULTSDIR)/unit-coverage-summary.txt
|
||||
LINTCMD = $(TEMPDIR)/golangci-lint run --tests=false --timeout=2m --config .golangci.yaml
|
||||
RELEASE_CMD=$(TEMPDIR)/goreleaser release --rm-dist
|
||||
SNAPSHOT_CMD=$(RELEASE_CMD) --skip-publish --snapshot
|
||||
COMPARE_TEST_IMAGE = centos:8.2.2004
|
||||
COMPARE_DIR = ./test/compare
|
||||
|
||||
TOOL_DIR = .tool
|
||||
BINNY = $(TOOL_DIR)/binny
|
||||
TASK = $(TOOL_DIR)/task
|
||||
# formatting variables
|
||||
BOLD := $(shell tput -T linux bold)
|
||||
PURPLE := $(shell tput -T linux setaf 5)
|
||||
GREEN := $(shell tput -T linux setaf 2)
|
||||
CYAN := $(shell tput -T linux setaf 6)
|
||||
RED := $(shell tput -T linux setaf 1)
|
||||
RESET := $(shell tput -T linux sgr0)
|
||||
TITLE := $(BOLD)$(PURPLE)
|
||||
SUCCESS := $(BOLD)$(GREEN)
|
||||
|
||||
.DEFAULT_GOAL := make-default
|
||||
# the quality gate lower threshold for unit test total % coverage (by function statements)
|
||||
COVERAGE_THRESHOLD := 62
|
||||
|
||||
## Bootstrapping targets #################################
|
||||
# CI cache busting values; change these if you want CI to not use previous stored cache
|
||||
INTEGRATION_CACHE_BUSTER="894d8ca"
|
||||
CLI_CACHE_BUSTER="894d8ca"
|
||||
BOOTSTRAP_CACHE="c7afb99ad"
|
||||
|
||||
# note: we need to assume that binny and task have not already been installed
|
||||
$(BINNY):
|
||||
@mkdir -p $(TOOL_DIR)
|
||||
@curl -sSfL https://raw.githubusercontent.com/$(OWNER)/binny/main/install.sh | sh -s -- -b $(TOOL_DIR)
|
||||
## Build variables
|
||||
DISTDIR=./dist
|
||||
SNAPSHOTDIR=./snapshot
|
||||
OS=$(shell uname | tr '[:upper:]' '[:lower:]')
|
||||
SNAPSHOT_BIN=$(shell realpath $(shell pwd)/$(SNAPSHOTDIR)/$(OS)-build_$(OS)_amd64/$(BIN))
|
||||
|
||||
# note: we need to assume that binny and task have not already been installed
|
||||
.PHONY: task
|
||||
$(TASK) task: $(BINNY)
|
||||
@$(BINNY) install task -q
|
||||
## Variable assertions
|
||||
|
||||
.PHONY: ci-bootstrap-go
|
||||
ci-bootstrap-go:
|
||||
ifndef TEMPDIR
|
||||
$(error TEMPDIR is not set)
|
||||
endif
|
||||
|
||||
ifndef RESULTSDIR
|
||||
$(error RESULTSDIR is not set)
|
||||
endif
|
||||
|
||||
ifndef COMPARE_DIR
|
||||
$(error COMPARE_DIR is not set)
|
||||
endif
|
||||
|
||||
ifndef DISTDIR
|
||||
$(error DISTDIR is not set)
|
||||
endif
|
||||
|
||||
ifndef SNAPSHOTDIR
|
||||
$(error SNAPSHOTDIR is not set)
|
||||
endif
|
||||
|
||||
ifndef REF_NAME
|
||||
REF_NAME = $(VERSION)
|
||||
endif
|
||||
|
||||
define title
|
||||
@printf '$(TITLE)$(1)$(RESET)\n'
|
||||
endef
|
||||
|
||||
define safe_rm_rf
|
||||
bash -c 'test -z "$(1)" && false || rm -rf $(1)'
|
||||
endef
|
||||
|
||||
define safe_rm_rf_children
|
||||
bash -c 'test -z "$(1)" && false || rm -rf $(1)/*'
|
||||
endef
|
||||
|
||||
## Tasks
|
||||
|
||||
.PHONY: all
|
||||
all: clean static-analysis test ## Run all linux-based checks (linting, license check, unit, integration, and linux compare tests)
|
||||
@printf '$(SUCCESS)All checks pass!$(RESET)\n'
|
||||
|
||||
.PHONY: test
|
||||
test: unit validate-cyclonedx-schema integration benchmark compare-linux cli ## Run all tests (currently unit, integration, linux compare, and cli tests)
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(BOLD)$(CYAN)%-25s$(RESET)%s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: ci-bootstrap
|
||||
ci-bootstrap:
|
||||
DEBIAN_FRONTEND=noninteractive sudo apt update && sudo -E apt install -y bc jq libxml2-utils
|
||||
|
||||
.PHONY:
|
||||
ci-bootstrap-mac:
|
||||
github_changelog_generator --version || sudo gem install github_changelog_generator
|
||||
|
||||
$(RESULTSDIR):
|
||||
mkdir -p $(RESULTSDIR)
|
||||
|
||||
$(TEMPDIR):
|
||||
mkdir -p $(TEMPDIR)
|
||||
|
||||
.PHONY: bootstrap-tools
|
||||
bootstrap-tools: $(TEMPDIR)
|
||||
GO111MODULE=off GOBIN=$(shell realpath $(TEMPDIR)) go get -u golang.org/x/perf/cmd/benchstat
|
||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(TEMPDIR)/ v1.42.1
|
||||
curl -sSfL https://raw.githubusercontent.com/wagoodman/go-bouncer/master/bouncer.sh | sh -s -- -b $(TEMPDIR)/ v0.3.0
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/chronicle/main/install.sh | sh -s -- -b $(TEMPDIR)/ v0.3.0
|
||||
.github/scripts/goreleaser-install.sh -d -b $(TEMPDIR)/ v1.3.1
|
||||
GOBIN="$(shell realpath $(TEMPDIR))" go install github.com/neilpa/yajsv@v1.4.0
|
||||
|
||||
.PHONY: bootstrap-go
|
||||
bootstrap-go:
|
||||
go mod download
|
||||
|
||||
# this is a bootstrapping catch-all, where if the target doesn't exist, we'll ensure the tools are installed and then try again
|
||||
%:
|
||||
@make --silent $(TASK)
|
||||
@$(TASK) $@
|
||||
.PHONY: bootstrap
|
||||
bootstrap: $(RESULTSDIR) bootstrap-go bootstrap-tools ## Download and install all go dependencies (+ prep tooling in the ./tmp dir)
|
||||
$(call title,Bootstrapping dependencies)
|
||||
|
||||
## Shim targets #################################
|
||||
.PHONY: static-analysis
|
||||
static-analysis: lint check-go-mod-tidy check-licenses
|
||||
|
||||
.PHONY: make-default
|
||||
make-default: $(TASK)
|
||||
@# run the default task in the taskfile
|
||||
@$(TASK)
|
||||
.PHONY: lint
|
||||
lint: ## Run gofmt + golangci lint checks
|
||||
$(call title,Running linters)
|
||||
# ensure there are no go fmt differences
|
||||
@printf "files with gofmt issues: [$(shell gofmt -l -s .)]\n"
|
||||
@test -z "$(shell gofmt -l -s .)"
|
||||
|
||||
# for those of us that can't seem to kick the habit of typing `make ...` lets wrap the superior `task` tool
|
||||
TASKS := $(shell bash -c "test -f $(TASK) && $(TASK) -l | grep '^\* ' | cut -d' ' -f2 | tr -d ':' | tr '\n' ' '" ) $(shell bash -c "test -f $(TASK) && $(TASK) -l | grep 'aliases:' | cut -d ':' -f 3 | tr '\n' ' ' | tr -d ','")
|
||||
# run all golangci-lint rules
|
||||
$(LINTCMD)
|
||||
|
||||
.PHONY: $(TASKS)
|
||||
$(TASKS): $(TASK)
|
||||
@$(TASK) $@
|
||||
# go tooling does not play well with certain filename characters, ensure the common cases don't result in future "go get" failures
|
||||
$(eval MALFORMED_FILENAMES := $(shell find . | grep -e ':'))
|
||||
@bash -c "[[ '$(MALFORMED_FILENAMES)' == '' ]] || (printf '\nfound unsupported filename characters:\n$(MALFORMED_FILENAMES)\n\n' && false)"
|
||||
|
||||
help: $(TASK)
|
||||
@$(TASK) -l
|
||||
.PHONY: lint-fix
|
||||
lint-fix: ## Auto-format all source code + run golangci lint fixers
|
||||
$(call title,Running lint fixers)
|
||||
gofmt -w -s .
|
||||
$(LINTCMD) --fix
|
||||
go mod tidy
|
||||
|
||||
.PHONY: check-licenses
|
||||
check-licenses: ## Ensure transitive dependencies are compliant with the current license policy
|
||||
$(TEMPDIR)/bouncer check
|
||||
|
||||
check-go-mod-tidy:
|
||||
@ .github/scripts/go-mod-tidy-check.sh && echo "go.mod and go.sum are tidy!"
|
||||
|
||||
.PHONY: validate-cyclonedx-schema
|
||||
validate-cyclonedx-schema:
|
||||
cd schema/cyclonedx && make
|
||||
|
||||
.PHONY: unit
|
||||
unit: $(RESULTSDIR) fixtures ## Run unit tests (with coverage)
|
||||
$(call title,Running unit tests)
|
||||
go test -coverprofile $(COVER_REPORT) $(shell go list ./... | grep -v anchore/syft/test)
|
||||
@go tool cover -func $(COVER_REPORT) | grep total | awk '{print substr($$3, 1, length($$3)-1)}' > $(COVER_TOTAL)
|
||||
@echo "Coverage: $$(cat $(COVER_TOTAL))"
|
||||
@if [ $$(echo "$$(cat $(COVER_TOTAL)) >= $(COVERAGE_THRESHOLD)" | bc -l) -ne 1 ]; then echo "$(RED)$(BOLD)Failed coverage quality gate (> $(COVERAGE_THRESHOLD)%)$(RESET)" && false; fi
|
||||
|
||||
.PHONY: benchmark
|
||||
benchmark: $(RESULTSDIR) ## Run benchmark tests and compare against the baseline (if available)
|
||||
$(call title,Running benchmark tests)
|
||||
go test -p 1 -run=^Benchmark -bench=. -count=5 -benchmem ./... | tee $(RESULTSDIR)/benchmark-$(REF_NAME).txt
|
||||
(test -s $(RESULTSDIR)/benchmark-main.txt && \
|
||||
$(TEMPDIR)/benchstat $(RESULTSDIR)/benchmark-main.txt $(RESULTSDIR)/benchmark-$(REF_NAME).txt || \
|
||||
$(TEMPDIR)/benchstat $(RESULTSDIR)/benchmark-$(REF_NAME).txt) \
|
||||
| tee $(RESULTSDIR)/benchstat.txt
|
||||
|
||||
.PHONY: show-benchstat
|
||||
show-benchstat:
|
||||
@cat $(RESULTSDIR)/benchstat.txt
|
||||
|
||||
# note: this is used by CI to determine if the install test fixture cache (docker image tars) should be busted
|
||||
install-fingerprint:
|
||||
cd test/install && \
|
||||
make cache.fingerprint
|
||||
|
||||
install-test: $(SNAPSHOTDIR)
|
||||
cd test/install && \
|
||||
make
|
||||
|
||||
install-test-cache-save: $(SNAPSHOTDIR)
|
||||
cd test/install && \
|
||||
make save
|
||||
|
||||
install-test-cache-load: $(SNAPSHOTDIR)
|
||||
cd test/install && \
|
||||
make load
|
||||
|
||||
install-test-ci-mac: $(SNAPSHOTDIR)
|
||||
cd test/install && \
|
||||
make ci-test-mac
|
||||
|
||||
.PHONY: integration
|
||||
integration: ## Run integration tests
|
||||
$(call title,Running integration tests)
|
||||
go test -v ./test/integration
|
||||
|
||||
# note: this is used by CI to determine if the integration test fixture cache (docker image tars) should be busted
|
||||
integration-fingerprint:
|
||||
$(call title,Integration test fixture fingerprint)
|
||||
find test/integration/test-fixtures/image-* -type f -exec md5sum {} + | awk '{print $1}' | sort | tee /dev/stderr | md5sum | tee test/integration/test-fixtures/cache.fingerprint && echo "$(INTEGRATION_CACHE_BUSTER)" >> test/integration/test-fixtures/cache.fingerprint
|
||||
|
||||
.PHONY: java-packages-fingerprint
|
||||
java-packages-fingerprint:
|
||||
$(call title,Java test fixture fingerprint)
|
||||
cd syft/pkg/cataloger/java/test-fixtures/java-builds && \
|
||||
make packages.fingerprint
|
||||
|
||||
.PHONY: fixtures
|
||||
fixtures:
|
||||
$(call title,Generating test fixtures)
|
||||
cd syft/pkg/cataloger/java/test-fixtures/java-builds && make
|
||||
|
||||
.PHONY: generate-json-schema
|
||||
generate-json-schema: ## Generate a new json schema
|
||||
cd schema/json && go run generate.go
|
||||
|
||||
.PHONY: generate-license-list
|
||||
generate-license-list: ## Generate an updated spdx license list
|
||||
go generate ./internal/spdxlicense/...
|
||||
gofmt -s -w ./internal/spdxlicense
|
||||
|
||||
.PHONY: build
|
||||
build: $(SNAPSHOTDIR) ## Build release snapshot binaries and packages
|
||||
|
||||
$(SNAPSHOTDIR): ## Build snapshot release binaries and packages
|
||||
$(call title,Building snapshot artifacts)
|
||||
|
||||
# create a config with the dist dir overridden
|
||||
echo "dist: $(SNAPSHOTDIR)" > $(TEMPDIR)/goreleaser.yaml
|
||||
cat .goreleaser.yaml >> $(TEMPDIR)/goreleaser.yaml
|
||||
|
||||
# build release snapshots
|
||||
bash -c "SKIP_SIGNING=true $(SNAPSHOT_CMD) --skip-sign --config $(TEMPDIR)/goreleaser.yaml"
|
||||
|
||||
.PHONY: snapshot-with-signing
|
||||
snapshot-with-signing: ## Build snapshot release binaries and packages (with dummy signing)
|
||||
$(call title,Building snapshot artifacts (+ signing))
|
||||
|
||||
# create a config with the dist dir overridden
|
||||
echo "dist: $(SNAPSHOTDIR)" > $(TEMPDIR)/goreleaser.yaml
|
||||
cat .goreleaser.yaml >> $(TEMPDIR)/goreleaser.yaml
|
||||
|
||||
rm -f .github/scripts/apple-signing/log/*.txt
|
||||
|
||||
# build release snapshots
|
||||
bash -c "$(SNAPSHOT_CMD) --config $(TEMPDIR)/goreleaser.yaml || (cat .github/scripts/apple-signing/log/*.txt && false)"
|
||||
|
||||
# remove the keychain with the trusted self-signed cert automatically
|
||||
.github/scripts/apple-signing/cleanup.sh
|
||||
|
||||
# note: we cannot clean the snapshot directory since the pipeline builds the snapshot separately
|
||||
.PHONY: compare-mac
|
||||
compare-mac: $(RESULTSDIR) $(SNAPSHOTDIR) ## Run compare tests on build snapshot binaries and packages (Mac)
|
||||
$(call title,Running compare test: Run on Mac)
|
||||
$(COMPARE_DIR)/mac.sh \
|
||||
$(SNAPSHOTDIR) \
|
||||
$(COMPARE_DIR) \
|
||||
$(COMPARE_TEST_IMAGE) \
|
||||
$(RESULTSDIR)
|
||||
|
||||
# note: we cannot clean the snapshot directory since the pipeline builds the snapshot separately
|
||||
.PHONY: compare-linux
|
||||
compare-linux: compare-test-deb-package-install compare-test-rpm-package-install ## Run compare tests on build snapshot binaries and packages (Linux)
|
||||
|
||||
.PHONY: compare-test-deb-package-install
|
||||
compare-test-deb-package-install: $(RESULTSDIR) $(SNAPSHOTDIR)
|
||||
$(call title,Running compare test: DEB install)
|
||||
$(COMPARE_DIR)/deb.sh \
|
||||
$(SNAPSHOTDIR) \
|
||||
$(COMPARE_DIR) \
|
||||
$(COMPARE_TEST_IMAGE) \
|
||||
$(RESULTSDIR)
|
||||
|
||||
.PHONY: compare-test-rpm-package-install
|
||||
compare-test-rpm-package-install: $(RESULTSDIR) $(SNAPSHOTDIR)
|
||||
$(call title,Running compare test: RPM install)
|
||||
$(COMPARE_DIR)/rpm.sh \
|
||||
$(SNAPSHOTDIR) \
|
||||
$(COMPARE_DIR) \
|
||||
$(COMPARE_TEST_IMAGE) \
|
||||
$(RESULTSDIR)
|
||||
|
||||
# note: this is used by CI to determine if the integration test fixture cache (docker image tars) should be busted
|
||||
cli-fingerprint:
|
||||
$(call title,CLI test fixture fingerprint)
|
||||
find test/cli/test-fixtures/image-* -type f -exec md5sum {} + | awk '{print $1}' | sort | md5sum | tee test/cli/test-fixtures/cache.fingerprint && echo "$(CLI_CACHE_BUSTER)" >> test/cli/test-fixtures/cache.fingerprint
|
||||
|
||||
.PHONY: cli
|
||||
cli: $(SNAPSHOTDIR) ## Run CLI tests
|
||||
chmod 755 "$(SNAPSHOT_BIN)"
|
||||
$(SNAPSHOT_BIN) version
|
||||
SYFT_BINARY_LOCATION='$(SNAPSHOT_BIN)' \
|
||||
go test -count=1 -v ./test/cli
|
||||
|
||||
.PHONY: changelog
|
||||
changelog: clean-changelog CHANGELOG.md
|
||||
@docker run -it --rm \
|
||||
-v $(shell pwd)/CHANGELOG.md:/CHANGELOG.md \
|
||||
rawkode/mdv \
|
||||
-t 748.5989 \
|
||||
/CHANGELOG.md
|
||||
|
||||
CHANGELOG.md:
|
||||
$(TEMPDIR)/chronicle -vv > CHANGELOG.md
|
||||
|
||||
.PHONY: release
|
||||
release: clean-dist CHANGELOG.md ## Build and publish final binaries and packages. Intended to be run only on macOS.
|
||||
$(call title,Publishing release artifacts)
|
||||
|
||||
# create a config with the dist dir overridden
|
||||
echo "dist: $(DISTDIR)" > $(TEMPDIR)/goreleaser.yaml
|
||||
cat .goreleaser.yaml >> $(TEMPDIR)/goreleaser.yaml
|
||||
|
||||
rm -f .github/scripts/apple-signing/log/*.txt
|
||||
|
||||
bash -c "\
|
||||
$(RELEASE_CMD) \
|
||||
--config $(TEMPDIR)/goreleaser.yaml \
|
||||
--release-notes <(cat CHANGELOG.md)\
|
||||
|| cat .github/scripts/apple-signing/log/*.txt && false"
|
||||
|
||||
cat .github/scripts/apple-signing/log/*.txt
|
||||
|
||||
# upload the version file that supports the application version update check (excluding pre-releases)
|
||||
.github/scripts/update-version-file.sh "$(DISTDIR)" "$(VERSION)"
|
||||
|
||||
|
||||
.PHONY: clean
|
||||
clean: clean-dist clean-snapshot clean-test-image-cache ## Remove previous builds, result reports, and test cache
|
||||
$(call safe_rm_rf_children,$(RESULTSDIR))
|
||||
|
||||
.PHONY: clean-snapshot
|
||||
clean-snapshot:
|
||||
$(call safe_rm_rf,$(SNAPSHOTDIR))
|
||||
rm -f $(TEMPDIR)/goreleaser.yaml
|
||||
|
||||
.PHONY: clean-dist
|
||||
clean-dist: clean-changelog
|
||||
$(call safe_rm_rf,$(DISTDIR))
|
||||
rm -f $(TEMPDIR)/goreleaser.yaml
|
||||
|
||||
.PHONY: clean-changelog
|
||||
clean-changelog:
|
||||
rm -f CHANGELOG.md
|
||||
|
||||
clean-test-image-cache: clean-test-image-tar-cache clean-test-image-docker-cache
|
||||
|
||||
.PHONY: clear-test-image-tar-cache
|
||||
clean-test-image-tar-cache: ## Delete all test cache (built docker image tars)
|
||||
find . -type f -wholename "**/test-fixtures/cache/stereoscope-fixture-*.tar" -delete
|
||||
|
||||
.PHONY: clear-test-image-docker-cache
|
||||
clean-test-image-docker-cache: ## Purge all test docker images
|
||||
docker images --format '{{.ID}} {{.Repository}}' | grep stereoscope-fixture- | awk '{print $$1}' | uniq | xargs docker rmi --force
|
||||
|
||||
.PHONY: show-test-image-cache
|
||||
show-test-image-cache: ## Show all docker and image tar cache
|
||||
$(call title,Docker daemon cache)
|
||||
@docker images --format '{{.ID}} {{.Repository}}:{{.Tag}}' | grep stereoscope-fixture- | sort
|
||||
|
||||
$(call title,Tar cache)
|
||||
@find . -type f -wholename "**/test-fixtures/cache/stereoscope-fixture-*.tar" | sort
|
||||
|
||||
.PHONY: show-test-snapshots
|
||||
show-test-snapshots: ## Show all test snapshots
|
||||
$(call title,Test snapshots)
|
||||
@find . -type f -wholename "**/test-fixtures/snapshot/*" | sort
|
||||
|
||||
@@ -2,186 +2,446 @@
|
||||
<img src="https://user-images.githubusercontent.com/5199289/136844524-1527b09f-c5cb-4aa9-be54-5aa92a6086c1.png" width="271" alt="Cute pink owl syft logo">
|
||||
</p>
|
||||
|
||||
# Syft
|
||||
[](https://github.com/anchore/syft/actions/workflows/validations.yaml)
|
||||
[](https://goreportcard.com/report/github.com/anchore/syft)
|
||||
[](https://github.com/anchore/syft/releases/latest)
|
||||
[](https://github.com/anchore/syft)
|
||||
[](https://github.com/anchore/syft/blob/main/LICENSE)
|
||||
[](https://anchore.com/slack)
|
||||
|
||||
**A CLI tool and Go library for generating a Software Bill of Materials (SBOM) from container images and filesystems. Exceptional for vulnerability detection when used with a scanner like [Grype](https://github.com/anchore/grype).**
|
||||
A CLI tool and Go library for generating a Software Bill of Materials (SBOM) from container images and filesystems. Exceptional for vulnerability detection when used with a scanner tool like [Grype](https://github.com/anchore/grype).
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/anchore/syft/actions/workflows/validations.yaml" target="_blank"><img alt="Validations" src="https://github.com/anchore/syft/actions/workflows/validations.yaml/badge.svg"></a>
|
||||
<a href="https://goreportcard.com/report/github.com/anchore/syft" target="_blank"><img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/anchore/syft"></a>
|
||||
<a href="https://github.com/anchore/syft/releases/latest" target="_blank"><img alt="GitHub release" src="https://img.shields.io/github/release/anchore/syft.svg"></a>
|
||||
<a href="https://github.com/anchore/syft" target="_blank"><img alt="GitHub go.mod Go version" src="https://img.shields.io/github/go-mod/go-version/anchore/syft.svg"></a>
|
||||
<a href="" target="_blank"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/License-Apache%202.0-blue.svg"></a>
|
||||
<a href="https://anchore.com/discourse" target="_blank"><img alt="Join our Discourse" src="https://img.shields.io/badge/Discourse-Join-blue?logo=discourse"/></a>
|
||||
<a rel="me" href="https://fosstodon.org/@syft"><img alt="Follow on Mastodon" src="https://img.shields.io/badge/Mastodon-Follow-blue?logoColor=white&logo=mastodon"/></a>
|
||||
</p>
|
||||
### Join our community meetings!
|
||||
|
||||
- Calendar: https://calendar.google.com/calendar/u/0/r?cid=Y182OTM4dGt0MjRtajI0NnNzOThiaGtnM29qNEBncm91cC5jYWxlbmRhci5nb29nbGUuY29t
|
||||
- Agenda: https://docs.google.com/document/d/1ZtSAa6fj2a6KRWviTn3WoJm09edvrNUp4Iz_dOjjyY8/edit?usp=sharing (join [this group](https://groups.google.com/g/anchore-oss-community) for write access)
|
||||
- All are welcome!
|
||||
|
||||

|
||||
|
||||
## Introduction
|
||||
|
||||
Syft is a powerful and easy-to-use open-source tool for generating Software Bill of Materials (SBOMs) for container images and filesystems. It provides detailed visibility into the packages and dependencies in your software, helping you manage vulnerabilities, license compliance, and software supply chain security.
|
||||
|
||||
Syft development is sponsored by [Anchore](https://anchore.com/), and is released under the [Apache-2.0 License](https://github.com/anchore/syft?tab=Apache-2.0-1-ov-file). For commercial support options with Syft or Grype, please [contact Anchore](https://get.anchore.com/contact/).
|
||||
|
||||
## Features
|
||||
- Generates SBOMs for container images, filesystems, archives, and more to discover packages and libraries
|
||||
- Supports OCI, Docker and [Singularity](https://github.com/sylabs/singularity) image formats
|
||||
- Linux distribution identification
|
||||
- Works seamlessly with [Grype](https://github.com/anchore/grype) (a fast, modern vulnerability scanner)
|
||||
- Able to create signed SBOM attestations using the [in-toto specification](https://github.com/in-toto/attestation/blob/main/spec/README.md)
|
||||
- Convert between SBOM formats, such as CycloneDX, SPDX, and Syft's own format.
|
||||
- Catalog container images and filesystems to discover packages and libraries.
|
||||
- Supports packages and libraries from various ecosystems (APK, DEB, RPM, Ruby Bundles, Python Wheel/Egg/requirements.txt, JavaScript NPM/Yarn, Java JAR/EAR/WAR/PAR/SAR, Jenkins plugins JPI/HPI, Go modules, PHP Composer)
|
||||
- Linux distribution identification (supports Alpine, BusyBox, CentOS/RedHat, Debian/Ubuntu flavored distributions)
|
||||
- Supports Docker and OCI image formats
|
||||
- Direct support for [Grype](https://github.com/anchore/grype), a fast and powerful vulnerability matcher.
|
||||
|
||||
|
||||
If you encounter an issue, please [let us know using the issue tracker](https://github.com/anchore/syft/issues).
|
||||
|
||||
## Installation
|
||||
|
||||
Syft binaries are provided for Linux, macOS and Windows.
|
||||
|
||||
### Recommended
|
||||
> ```bash
|
||||
> curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
> ```
|
||||
```bash
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
```
|
||||
|
||||
Install script options:
|
||||
- `-b`: Specify a custom installation directory (defaults to `./bin`)
|
||||
- `-d`: More verbose logging levels (`-d` for debug, `-dd` for trace)
|
||||
- `-v`: Verify the signature of the downloaded artifact before installation (requires [`cosign`](https://github.com/sigstore/cosign) to be installed)
|
||||
...or, you can specify a release version and destination directory for the installation:
|
||||
|
||||
```
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b <DESTINATION_DIR> <RELEASE_VERSION>
|
||||
```
|
||||
|
||||
### Homebrew
|
||||
```bash
|
||||
brew tap anchore/syft
|
||||
brew install syft
|
||||
```
|
||||
|
||||
### Scoop
|
||||
|
||||
```powershell
|
||||
scoop install syft
|
||||
```
|
||||
|
||||
### Chocolatey
|
||||
|
||||
The chocolatey distribution of Syft is community-maintained and not distributed by the Anchore team
|
||||
|
||||
```powershell
|
||||
choco install syft -y
|
||||
```
|
||||
|
||||
### Nix
|
||||
|
||||
**Note**: Nix packaging of Syft is [community maintained](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/sy/syft/package.nix). Syft is available in the [stable channel](https://wiki.nixos.org/wiki/Nix_channels#The_official_channels) since NixOS `22.05`.
|
||||
|
||||
```bash
|
||||
nix-env -i syft
|
||||
```
|
||||
|
||||
... or, just try it out in an ephemeral nix shell:
|
||||
|
||||
```bash
|
||||
nix-shell -p syft
|
||||
```
|
||||
**Note**: Currently, Syft is built only for macOS and Linux.
|
||||
|
||||
## Getting started
|
||||
|
||||
### SBOM
|
||||
|
||||
To generate an SBOM for a container image:
|
||||
|
||||
```bash
|
||||
To generate an SBOM for a Docker or OCI image:
|
||||
```
|
||||
syft <image>
|
||||
```
|
||||
|
||||
The above output includes only software that is visible in the container (i.e., the squashed representation of the image). To include software from all image layers in the SBOM, regardless of its presence in the final image, provide `--scope all-layers`:
|
||||
|
||||
```bash
|
||||
syft <image> --scope all-layers
|
||||
**Note**: This is equivalent to specifying the `packages` subcommand:
|
||||
```
|
||||
syft packages <image>
|
||||
```
|
||||
|
||||
The above output includes only software that is visible in the container (i.e., the squashed representation of the image).
|
||||
To include software from all image layers in the SBOM, regardless of its presence in the final image, provide `--scope all-layers`:
|
||||
|
||||
```
|
||||
syft packages <image> --scope all-layers
|
||||
```
|
||||
|
||||
### Supported sources
|
||||
|
||||
Syft can generate a SBOM from a variety of sources:
|
||||
|
||||
```
|
||||
# catalog a container image archive (from the result of `docker image save ...`, `podman save ...`, or `skopeo copy` commands)
|
||||
syft packages path/to/image.tar
|
||||
|
||||
# catalog a directory
|
||||
syft packages path/to/dir
|
||||
```
|
||||
|
||||
Sources can be explicitly provided with a scheme:
|
||||
|
||||
```
|
||||
docker:yourrepo/yourimage:tag use images from the Docker daemon
|
||||
docker-archive:path/to/yourimage.tar use a tarball from disk for archives created from "docker save"
|
||||
oci-archive:path/to/yourimage.tar use a tarball from disk for OCI archives (from Skopeo or otherwise)
|
||||
oci-dir:path/to/yourimage read directly from a path on disk for OCI layout directories (from Skopeo or otherwise)
|
||||
dir:path/to/yourproject read directly from a path on disk (any directory)
|
||||
file:path/to/yourproject/file read directly from a path on disk (any single file)
|
||||
registry:yourrepo/yourimage:tag pull image directly from a registry (no container runtime required)
|
||||
```
|
||||
|
||||
### Excluding file paths
|
||||
|
||||
Syft can exclude files and paths from being scanned within a source by using glob expressions
|
||||
with one or more `--exclude` parameters:
|
||||
```
|
||||
syft <source> --exclude './out/**/*.json' --exclude /etc
|
||||
```
|
||||
**Note:** in the case of _image scanning_, since the entire filesystem is scanned it is
|
||||
possible to use absolute paths like `/etc` or `/usr/**/*.txt` whereas _directory scans_
|
||||
exclude files _relative to the specified directory_. For example: scanning `/usr/foo` with
|
||||
`--exclude ./package.json` would exclude `/usr/foo/package.json` and `--exclude '**/package.json'`
|
||||
would exclude all `package.json` files under `/usr/foo`. For _directory scans_,
|
||||
it is required to begin path expressions with `./`, `*/`, or `**/`, all of which
|
||||
will be resolved _relative to the specified scan directory_. Keep in mind, your shell
|
||||
may attempt to expand wildcards, so put those parameters in single quotes, like:
|
||||
`'**/*.json'`.
|
||||
|
||||
### Output formats
|
||||
|
||||
The output format for Syft is configurable as well using the `-o` (or `--output`) option:
|
||||
The output format for Syft is configurable as well using the
|
||||
`-o` (or `--output`) option:
|
||||
|
||||
```
|
||||
syft <image> -o <format>
|
||||
syft packages <image> -o <format>
|
||||
```
|
||||
|
||||
Where the `formats` available are:
|
||||
- `syft-json`: Use this to get as much information out of Syft as possible!
|
||||
- `syft-text`: A row-oriented, human-and-machine-friendly output.
|
||||
- `cyclonedx-xml`: A XML report conforming to the [CycloneDX 1.6 specification](https://cyclonedx.org/specification/overview/).
|
||||
- `cyclonedx-xml@1.5`: A XML report conforming to the [CycloneDX 1.5 specification](https://cyclonedx.org/specification/overview/).
|
||||
- `cyclonedx-json`: A JSON report conforming to the [CycloneDX 1.6 specification](https://cyclonedx.org/specification/overview/).
|
||||
- `cyclonedx-json@1.5`: A JSON report conforming to the [CycloneDX 1.5 specification](https://cyclonedx.org/specification/overview/).
|
||||
- `spdx-tag-value`: A tag-value formatted report conforming to the [SPDX 2.3 specification](https://spdx.github.io/spdx-spec/v2.3/).
|
||||
- `spdx-tag-value@2.2`: A tag-value formatted report conforming to the [SPDX 2.2 specification](https://spdx.github.io/spdx-spec/v2.2.2/).
|
||||
- `spdx-json`: A JSON report conforming to the [SPDX 2.3 JSON Schema](https://github.com/spdx/spdx-spec/blob/v2.3/schemas/spdx-schema.json).
|
||||
- `spdx-json@2.2`: A JSON report conforming to the [SPDX 2.2 JSON Schema](https://github.com/spdx/spdx-spec/blob/v2.2/schemas/spdx-schema.json).
|
||||
- `github-json`: A JSON report conforming to GitHub's dependency snapshot format.
|
||||
- `syft-table`: A columnar summary (default).
|
||||
- `template`: Lets the user specify the output format. See ["Using templates"](#using-templates) below.
|
||||
- `json`: Use this to get as much information out of Syft as possible!
|
||||
- `text`: A row-oriented, human-and-machine-friendly output.
|
||||
- `cyclonedx`: A XML report conforming to the [CycloneDX 1.3 specification](https://cyclonedx.org/specification/overview/).
|
||||
- `cyclonedx-json`: A JSON report conforming to the [CycloneDX 1.3 specification](https://cyclonedx.org/specification/overview/).
|
||||
- `spdx`: A tag-value formatted report conforming to the [SPDX 2.2 specification](https://spdx.github.io/spdx-spec/).
|
||||
- `spdx-json`: A JSON report conforming to the [SPDX 2.2 JSON Schema](https://github.com/spdx/spdx-spec/blob/v2.2/schemas/spdx-schema.json).
|
||||
- `table`: A columnar summary (default).
|
||||
|
||||
Note that flags using the @<version> can be used for earlier versions of each specification as well.
|
||||
#### Multiple outputs
|
||||
|
||||
### Supported Ecosystems
|
||||
Syft can also output _multiple_ files in differing formats by appending
|
||||
`=<file>` to the option, for example to output Syft JSON and SPDX JSON:
|
||||
|
||||
- Alpine (apk)
|
||||
- Bitnami packages
|
||||
- C (conan)
|
||||
- C++ (conan)
|
||||
- Dart (pubs)
|
||||
- Debian (dpkg)
|
||||
- Dotnet (deps.json)
|
||||
- Objective-C (cocoapods)
|
||||
- Elixir (mix)
|
||||
- Erlang (rebar3)
|
||||
- Go (go.mod, Go binaries)
|
||||
- Haskell (cabal, stack)
|
||||
- Java (jar, ear, war, par, sar, nar, native-image)
|
||||
- JavaScript (npm, yarn)
|
||||
- Jenkins Plugins (jpi, hpi)
|
||||
- Linux kernel archives (vmlinz)
|
||||
- Linux kernel modules (ko)
|
||||
- Nix (outputs in /nix/store)
|
||||
- PHP (composer, PECL, Pear)
|
||||
- Python (wheel, egg, poetry, requirements.txt)
|
||||
- Red Hat (rpm)
|
||||
- Ruby (gem)
|
||||
- Rust (cargo.lock, auditable binary)
|
||||
- Swift (cocoapods, swift-package-manager)
|
||||
- Wordpress plugins
|
||||
- Terraform providers (.terraform.lock.hcl)
|
||||
```shell
|
||||
syft packages <image> -o json=sbom.syft.json -o spdx-json=sbom.spdx.json
|
||||
```
|
||||
|
||||
## Documentation
|
||||
## Private Registry Authentication
|
||||
|
||||
Our [wiki](https://github.com/anchore/syft/wiki) contains further details on the following topics:
|
||||
### Local Docker Credentials
|
||||
When a container runtime is not present, Syft can still utilize credentials configured in common credential sources (such as `~/.docker/config.json`).
|
||||
It will pull images from private registries using these credentials. The config file is where your credentials are stored when authenticating with private registries via some command like `docker login`.
|
||||
For more information see the `go-containerregistry` [documentation](https://github.com/google/go-containerregistry/tree/main/pkg/authn).
|
||||
|
||||
* [Supported Sources](https://github.com/anchore/syft/wiki/supported-sources)
|
||||
* [File Selection](https://github.com/anchore/syft/wiki/file-selection)
|
||||
* [Excluding file paths](https://github.com/anchore/syft/wiki/excluding-file-paths)
|
||||
* [Output formats](https://github.com/anchore/syft/wiki/output-formats)
|
||||
* [Package Cataloger Selection](https://github.com/anchore/syft/wiki/package-cataloger-selection)
|
||||
* [Concepts](https://github.com/anchore/syft/wiki/package-cataloger-selection#concepts)
|
||||
* [Examples](https://github.com/anchore/syft/wiki/package-cataloger-selection#examples)
|
||||
* [Using templates](https://github.com/anchore/syft/wiki/using-templates)
|
||||
* [Multiple outputs](https://github.com/anchore/syft/wiki/multiple-outputs)
|
||||
* [Private Registry Authentication](https://github.com/anchore/syft/wiki/private-registry-authentication)
|
||||
* [Local Docker Credentials](https://github.com/anchore/syft/wiki/private-registry-authentication#local-docker)
|
||||
* [Docker Credentials in Kubernetes](https://github.com/anchore/syft/wiki/private-registry-authentication#docker-credentials-in-kubernetes)
|
||||
* [Attestation (experimental)](https://github.com/anchore/syft/wiki/attestation)
|
||||
* [Keyless Support](https://github.com/anchore/syft/wiki/attestation#keyless-support)
|
||||
* [Local private key support](https://github.com/anchore/syft/wiki/attestation#local-private-key-support)
|
||||
* [Adding an SBOM to an image as an attestation using Syft](https://github.com/anchore/syft/wiki/attestation#adding-an-sbom-to-an-image-as-an-attestation-using-syft)
|
||||
* [Configuration](https://github.com/anchore/syft/wiki/configuration)
|
||||
|
||||
## Contributing
|
||||
An example `config.json` looks something like this:
|
||||
```
|
||||
// config.json
|
||||
{
|
||||
"auths": {
|
||||
"registry.example.com": {
|
||||
"username": "AzureDiamond",
|
||||
"password": "hunter2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Check out our [contributing](/CONTRIBUTING.md) guide and [developer](/DEVELOPING.md) docs.
|
||||
You can run the following command as an example. It details the mount/environment configuration a container needs to access a private registry:
|
||||
|
||||
## Syft Team Meetings
|
||||
`docker run -v ./config.json:/config/config.json -e "DOCKER_CONFIG=/config" anchore/syft:latest <private_image>`
|
||||
|
||||
The Syft Team hold regular community meetings online. All are welcome to join to bring topics for discussion.
|
||||
- Check the [calendar](https://calendar.google.com/calendar/u/0/r?cid=Y182OTM4dGt0MjRtajI0NnNzOThiaGtnM29qNEBncm91cC5jYWxlbmRhci5nb29nbGUuY29t) for the next meeting date.
|
||||
- Add items to the [agenda](https://docs.google.com/document/d/1ZtSAa6fj2a6KRWviTn3WoJm09edvrNUp4Iz_dOjjyY8/edit?usp=sharing) (join [this group](https://groups.google.com/g/anchore-oss-community) for write access to the [agenda](https://docs.google.com/document/d/1ZtSAa6fj2a6KRWviTn3WoJm09edvrNUp4Iz_dOjjyY8/edit?usp=sharing))
|
||||
- See you there!
|
||||
|
||||
## Syft Logo
|
||||
### Docker Credentials in Kubernetes
|
||||
The below section shows a simple workflow on how to mount this config file as a secret into a container on kubernetes.
|
||||
1. Create a secret. The value of `config.json` is important. It refers to the specification detailed [here](https://github.com/google/go-containerregistry/tree/main/pkg/authn#the-config-file).
|
||||
Below this section is the `secret.yaml` file that the pod configuration will consume as a volume.
|
||||
The key `config.json` is important. It will end up being the name of the file when mounted into the pod.
|
||||
```
|
||||
# secret.yaml
|
||||
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: registry-config
|
||||
namespace: syft
|
||||
data:
|
||||
config.json: <base64 encoded config.json>
|
||||
```
|
||||
|
||||
<p xmlns:cc="http://creativecommons.org/ns#" xmlns:dct="http://purl.org/dc/terms/"><a property="dct:title" rel="cc:attributionURL" href="https://anchore.com/wp-content/uploads/2024/11/syft-logo.svg">Syft Logo</a> by <a rel="cc:attributionURL dct:creator" property="cc:attributionName" href="https://anchore.com/">Anchore</a> is licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="license noopener noreferrer" style="display:inline-block;">CC BY 4.0<img style="height:22px!important;margin-left:3px;vertical-align:text-bottom;" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt=""><img style="height:22px!important;margin-left:3px;vertical-align:text-bottom;" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt=""></a></p>
|
||||
`kubectl apply -f secret.yaml`
|
||||
|
||||
|
||||
2. Create your pod running syft. The env `DOCKER_CONFIG` is important because it advertises where to look for the credential file.
|
||||
In the below example, setting `DOCKER_CONFIG=/config` informs syft that credentials can be found at `/config/config.json`.
|
||||
This is why we used `config.json` as the key for our secret. When mounted into containers the secrets' key is used as the filename.
|
||||
The `volumeMounts` section mounts our secret to `/config`. The `volumes` section names our volume and leverages the secret we created in step one.
|
||||
```
|
||||
# pod.yaml
|
||||
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
spec:
|
||||
containers:
|
||||
- image: anchore/syft:latest
|
||||
name: syft-private-registry-demo
|
||||
env:
|
||||
- name: DOCKER_CONFIG
|
||||
value: /config
|
||||
volumeMounts:
|
||||
- mountPath: /config
|
||||
name: registry-config
|
||||
readOnly: true
|
||||
args:
|
||||
- <private_image>
|
||||
volumes:
|
||||
- name: registry-config
|
||||
secret:
|
||||
secretName: registry-config
|
||||
```
|
||||
|
||||
`kubectl apply -f pod.yaml`
|
||||
|
||||
|
||||
3. The user can now run `kubectl logs syft-private-registry-demo`. The logs should show the syft analysis for the `<private_image>` provided in the pod configuration.
|
||||
|
||||
Using the above information, users should be able to configure private registry access without having to do so in the `grype` or `syft` configuration files.
|
||||
They will also not be dependent on a docker daemon, (or some other runtime software) for registry configuration and access.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration search paths:
|
||||
|
||||
- `.syft.yaml`
|
||||
- `.syft/config.yaml`
|
||||
- `~/.syft.yaml`
|
||||
- `<XDG_CONFIG_HOME>/syft/config.yaml`
|
||||
|
||||
Configuration options (example values are the default):
|
||||
|
||||
```yaml
|
||||
# the output format(s) of the SBOM report (options: table, text, json, spdx, ...)
|
||||
# same as -o, --output, and SYFT_OUTPUT env var
|
||||
# to specify multiple output files in differing formats, use a list:
|
||||
# output:
|
||||
# - "json=<syft-json-output-file>"
|
||||
# - "spdx-json=<spdx-json-output-file>"
|
||||
output: "table"
|
||||
|
||||
# suppress all output (except for the SBOM report)
|
||||
# same as -q ; SYFT_QUIET env var
|
||||
quiet: false
|
||||
|
||||
# same as --file; write output report to a file (default is to write to stdout)
|
||||
file: ""
|
||||
|
||||
# enable/disable checking for application updates on startup
|
||||
# same as SYFT_CHECK_FOR_APP_UPDATE env var
|
||||
check-for-app-update: true
|
||||
|
||||
# a list of globs to exclude from scanning. same as --exclude ; for example:
|
||||
# exclude:
|
||||
# - "/etc/**"
|
||||
# - "./out/**/*.json"
|
||||
exclude:
|
||||
|
||||
# cataloging packages is exposed through the packages and power-user subcommands
|
||||
package:
|
||||
|
||||
# search within archives that do contain a file index to search against (zip)
|
||||
# note: for now this only applies to the java package cataloger
|
||||
# SYFT_PACKAGE_SEARCH_INDEXED_ARCHIVES env var
|
||||
search-indexed-archives: true
|
||||
|
||||
# search within archives that do not contain a file index to search against (tar, tar.gz, tar.bz2, etc)
|
||||
# note: enabling this may result in a performance impact since all discovered compressed tars will be decompressed
|
||||
# note: for now this only applies to the java package cataloger
|
||||
# SYFT_PACKAGE_SEARCH_UNINDEXED_ARCHIVES env var
|
||||
search-unindexed-archives: false
|
||||
|
||||
cataloger:
|
||||
# enable/disable cataloging of packages
|
||||
# SYFT_PACKAGE_CATALOGER_ENABLED env var
|
||||
enabled: true
|
||||
|
||||
# the search space to look for packages (options: all-layers, squashed)
|
||||
# same as -s ; SYFT_PACKAGE_CATALOGER_SCOPE env var
|
||||
scope: "squashed"
|
||||
|
||||
# cataloging file classifications is exposed through the power-user subcommand
|
||||
file-classification:
|
||||
cataloger:
|
||||
# enable/disable cataloging of file classifications
|
||||
# SYFT_FILE_CLASSIFICATION_CATALOGER_ENABLED env var
|
||||
enabled: true
|
||||
|
||||
# the search space to look for file classifications (options: all-layers, squashed)
|
||||
# SYFT_FILE_CLASSIFICATION_CATALOGER_SCOPE env var
|
||||
scope: "squashed"
|
||||
|
||||
# cataloging file contents is exposed through the power-user subcommand
|
||||
file-contents:
|
||||
cataloger:
|
||||
# enable/disable cataloging of secrets
|
||||
# SYFT_FILE_CONTENTS_CATALOGER_ENABLED env var
|
||||
enabled: true
|
||||
|
||||
# the search space to look for secrets (options: all-layers, squashed)
|
||||
# SYFT_FILE_CONTENTS_CATALOGER_SCOPE env var
|
||||
scope: "squashed"
|
||||
|
||||
# skip searching a file entirely if it is above the given size (default = 1MB; unit = bytes)
|
||||
# SYFT_FILE_CONTENTS_SKIP_FILES_ABOVE_SIZE env var
|
||||
skip-files-above-size: 1048576
|
||||
|
||||
# file globs for the cataloger to match on
|
||||
# SYFT_FILE_CONTENTS_GLOBS env var
|
||||
globs: []
|
||||
|
||||
# cataloging file metadata is exposed through the power-user subcommand
|
||||
file-metadata:
|
||||
cataloger:
|
||||
# enable/disable cataloging of file metadata
|
||||
# SYFT_FILE_METADATA_CATALOGER_ENABLED env var
|
||||
enabled: true
|
||||
|
||||
# the search space to look for file metadata (options: all-layers, squashed)
|
||||
# SYFT_FILE_METADATA_CATALOGER_SCOPE env var
|
||||
scope: "squashed"
|
||||
|
||||
# the file digest algorithms to use when cataloging files (options: "sha256", "md5", "sha1")
|
||||
# SYFT_FILE_METADATA_DIGESTS env var
|
||||
digests: ["sha256"]
|
||||
|
||||
# cataloging secrets is exposed through the power-user subcommand
|
||||
secrets:
|
||||
cataloger:
|
||||
# enable/disable cataloging of secrets
|
||||
# SYFT_SECRETS_CATALOGER_ENABLED env var
|
||||
enabled: true
|
||||
|
||||
# the search space to look for secrets (options: all-layers, squashed)
|
||||
# SYFT_SECRETS_CATALOGER_SCOPE env var
|
||||
scope: "all-layers"
|
||||
|
||||
# show extracted secret values in the final JSON report
|
||||
# SYFT_SECRETS_REVEAL_VALUES env var
|
||||
reveal-values: false
|
||||
|
||||
# skip searching a file entirely if it is above the given size (default = 1MB; unit = bytes)
|
||||
# SYFT_SECRETS_SKIP_FILES_ABOVE_SIZE env var
|
||||
skip-files-above-size: 1048576
|
||||
|
||||
# name-regex pairs to consider when searching files for secrets. Note: the regex must match single line patterns
|
||||
# but may also have OPTIONAL multiline capture groups. Regexes with a named capture group of "value" will
|
||||
# use the entire regex to match, but the secret value will be assumed to be entirely contained within the
|
||||
# "value" named capture group.
|
||||
additional-patterns: {}
|
||||
|
||||
# names to exclude from the secrets search, valid values are: "aws-access-key", "aws-secret-key", "pem-private-key",
|
||||
# "docker-config-auth", and "generic-api-key". Note: this does not consider any names introduced in the
|
||||
# "secrets.additional-patterns" config option.
|
||||
# SYFT_SECRETS_EXCLUDE_PATTERN_NAMES env var
|
||||
exclude-pattern-names: []
|
||||
|
||||
# options when pulling directly from a registry via the "registry:" scheme
|
||||
registry:
|
||||
# skip TLS verification when communicating with the registry
|
||||
# SYFT_REGISTRY_INSECURE_SKIP_TLS_VERIFY env var
|
||||
insecure-skip-tls-verify: false
|
||||
# use http instead of https when connecting to the registry
|
||||
# SYFT_REGISTRY_INSECURE_USE_HTTP env var
|
||||
insecure-use-http: false
|
||||
|
||||
# credentials for specific registries
|
||||
auth:
|
||||
# the URL to the registry (e.g. "docker.io", "localhost:5000", etc.)
|
||||
# SYFT_REGISTRY_AUTH_AUTHORITY env var
|
||||
- authority: ""
|
||||
# SYFT_REGISTRY_AUTH_USERNAME env var
|
||||
username: ""
|
||||
# SYFT_REGISTRY_AUTH_PASSWORD env var
|
||||
password: ""
|
||||
# note: token and username/password are mutually exclusive
|
||||
# SYFT_REGISTRY_AUTH_TOKEN env var
|
||||
token: ""
|
||||
# - ... # note, more credentials can be provided via config file only
|
||||
|
||||
log:
|
||||
# use structured logging
|
||||
# same as SYFT_LOG_STRUCTURED env var
|
||||
structured: false
|
||||
|
||||
# the log level; note: detailed logging suppress the ETUI
|
||||
# same as SYFT_LOG_LEVEL env var
|
||||
level: "error"
|
||||
|
||||
# location to write the log file (default is not to have a log file)
|
||||
# same as SYFT_LOG_FILE env var
|
||||
file: ""
|
||||
|
||||
# uploading package SBOM is exposed through the packages subcommand
|
||||
anchore:
|
||||
# (feature-preview) the Anchore Enterprise Host or URL to upload results to (supported on Enterprise 3.0+)
|
||||
# same as -H ; SYFT_ANCHORE_HOST env var
|
||||
host: ""
|
||||
|
||||
# (feature-preview) the path after the host to the Anchore External API (supported on Enterprise 3.0+)
|
||||
# same as SYFT_ANCHORE_PATH env var
|
||||
path: ""
|
||||
|
||||
# (feature-preview) the username to authenticate against Anchore Enterprise (supported on Enterprise 3.0+)
|
||||
# same as -u ; SYFT_ANCHORE_USERNAME env var
|
||||
username: ""
|
||||
|
||||
# (feature-preview) the password to authenticate against Anchore Enterprise (supported on Enterprise 3.0+)
|
||||
# same as -p ; SYFT_ANCHORE_PASSWORD env var
|
||||
password: ""
|
||||
|
||||
# (feature-preview) path to dockerfile to be uploaded with the syft results to Anchore Enterprise (supported on Enterprise 3.0+)
|
||||
# same as -d ; SYFT_ANCHORE_DOCKERFILE env var
|
||||
dockerfile: ""
|
||||
```
|
||||
|
||||
### Adding an SBOM to an image as an attestation
|
||||
SBOMs themselves can serve as input to different analysis tools. The Anchore organization offers the vulnerability scanner
|
||||
[grype](https://github.com/anchore/grype) as one such tool.
|
||||
One of the foundational approaches to "trust" between tools is for producers to use the artifacts generated by syft as attestations to their images.
|
||||
The SBOM output of syft can be used with the [cosign](https://github.com/sigstore/cosign) tool to generate an attestation that is attached to a signed image.
|
||||
|
||||
#### Example attest
|
||||
Note for the following example replace `test/image:latest` with an image you own. You should also have push access to
|
||||
its remote reference. Replace $MY_PRIVATE_KEY with a private key you own or have generated with cosign.
|
||||
|
||||
```bash
|
||||
cosign sign --key $MY_PRIVATE_KEY test/image:latest
|
||||
syft test/image:latest -o json > test_latest_sbom.json
|
||||
cosign attest --predicate test_latest_sbom.json --key $MY_PRIVATE_KEY test/image:latest
|
||||
```
|
||||
|
||||
Verify the new attestation exists on your image
|
||||
```bash
|
||||
cosign verify-attestation -key $MY_PUBLIC_KEY test/image:latest | jq '.payload |= @base64d | .payload | fromjson | .predicate.Data | fromjson | .'
|
||||
```
|
||||
|
||||
You should see this output along with the attached SBOM.
|
||||
```
|
||||
Verification for test/image:latest --
|
||||
The following checks were performed on each of these signatures:
|
||||
- The cosign claims were validated
|
||||
- The signatures were verified against the specified public key
|
||||
- Any certificates were verified against the Fulcio roots.
|
||||
```
|
||||
|
||||
Consumers of your image can now trust that the SBOM associated with your image is correct and from a trusted source.
|
||||
|
||||
+94
-29
@@ -1,39 +1,104 @@
|
||||
# Release
|
||||
# Release Process
|
||||
|
||||
A release of syft comprises:
|
||||
- a new semver git tag from the current tip of the main branch
|
||||
- a new [github release](https://github.com/anchore/syft/releases) with a changelog and archived binary assets
|
||||
- docker images published to `ghcr.io` and `dockerhub`, including multi architecture images + manifest
|
||||
- [`anchore/homebrew-syft`](https://github.com/anchore/homebrew-syft) tap updated to point to assets in the latest github release
|
||||
A good release process has the following qualities:
|
||||
|
||||
1. There is a way to plan what should be in a release
|
||||
1. There is a way to see what is actually in a release
|
||||
1. Allow for different kinds of releases (major breaking vs backwards compatible
|
||||
enhancements vs patch updates)
|
||||
1. Specify a repeatable way to build and publish software artifacts
|
||||
|
||||
## Planning a release
|
||||
|
||||
To indicate a set of features to be released together add each issue to an in-repository
|
||||
Milestone named with major-minor version to be released (e.g. `v0.1`). It is OK for other
|
||||
features to be in the release that were not originally planned, and these issues and PRs
|
||||
do not need to be added to the Milestone in question. Only the set of features that, when
|
||||
completed, would allow the release to be considered complete. A Milestone is only used to:
|
||||
|
||||
- Plan what is desired to be in a release
|
||||
- Track progress to indicate when we may be ready to cut a new release
|
||||
|
||||
Not all releases need to be planned. For instance, patch releases for fixes should be
|
||||
released when they are ready and when releasing would not interfere with another current
|
||||
release (where some partial or breaking features have already been merged). Beta releases
|
||||
and release candidates should not be independently planned from the non-beta release. That
|
||||
is, the features for a `v0.1-beta.1` release should be planned under the `v0.1` Milestone,
|
||||
not under a separate `v0.1-beta.1` Milestone.
|
||||
|
||||
Unless necessary, feature releases should be small and frequent, which may obviate the
|
||||
need for regular release planning under a Milestone.
|
||||
|
||||
## What is in a release
|
||||
|
||||
Milestones are specifically for planning a release, not necessarily tracking all changes
|
||||
that a release may bring (and more importantly, not all releases are necessarily planned
|
||||
either).
|
||||
|
||||
This is one of the (many) reasons for a Changelog. A good Changelog lists changes grouped
|
||||
by the type of change (new, enhancement, deprecation, breaking, bug fix, security fix), in
|
||||
chronological order (within groups), linking the PR where the change was made in the
|
||||
Changelog line. Furthermore, there should be a place to see all released versions, the
|
||||
release date for each release, the semantic version of the release, and the set of changes
|
||||
for each release.
|
||||
|
||||
This project auto-generates the Changelog contents for each current release and posts the
|
||||
generated contents to the GitHub Release page. Leveraging the GitHub Releases feature
|
||||
allows GitHub to manage the Changelog on each release outside of the git repository while
|
||||
still being hosted with the released assets.
|
||||
|
||||
The Changelog is generated from the metadata from in-repository issues and PRs, using
|
||||
labels to guide what kind of change each item is (e.g. breaking, new feature, bug fix,
|
||||
etx). Only issues/PRs with select labels are included in the Changelog, and only if the
|
||||
issue/PR was created after the last release. Additional labels are used to exclude items
|
||||
from the Changelog.
|
||||
|
||||
The above suggestions imply that we should:
|
||||
|
||||
- Ensure there is a sufficient title for each PR and issue title to be included in the
|
||||
Changelog
|
||||
- The appropriate label is applied to PRs and/or issues to drive specific change type
|
||||
sections (deprecated, breaking, security, bug, etc)
|
||||
|
||||
With this approach as we cultivate good organization of PRs and issues we automatically
|
||||
get an equally good Changelog.
|
||||
|
||||
## Major, minor, and patch releases
|
||||
|
||||
The latest version of the tool is the only supported version, which implies that multiple
|
||||
parallel release branches will not be a regular process (if ever). Multiple releases can
|
||||
be planned in parallel, however, only one can be actively developed at a time. That is, if
|
||||
PRs attached to a release Milestone have been merged into the main branch, that release is
|
||||
now the "next" release. This implies that the source of truth for release lies with the
|
||||
git log and Changelog, not with the release Milestones (which are purely for planning and
|
||||
tracking).
|
||||
|
||||
Semantic versioning should be used to indicate breaking changes, new features, and fixes.
|
||||
The exception to this is `< 1.0`, where the major version is not bumped for breaking changes,
|
||||
instead the minor version indicates both new features and breaking changes.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
Ideally releasing should be done often with small increments when possible. Unless a
|
||||
breaking change is blocking the release, or no fixes/features have been merged, a good
|
||||
target release cadence is between every 1 or 2 weeks.
|
||||
target release cadence is between every 2 or 4 weeks.
|
||||
|
||||
This release process itself should be as automated as possible, and have only a few steps:
|
||||
|
||||
## Creating a release
|
||||
|
||||
This release process itself should be as automated as possible, and has only a few steps:
|
||||
|
||||
1. **Trigger a new release with `make release`**. At this point you'll see a preview
|
||||
changelog in the terminal. If you're happy with the changelog, press `y` to continue, otherwise
|
||||
you can abort and adjust the labels on the PRs and issues to be included in the release and
|
||||
re-run the release trigger command.
|
||||
|
||||
1. A release admin must approve the release on the GitHub Actions [release pipeline](https://github.com/anchore/syft/actions/workflows/release.yaml) run page.
|
||||
Once approved, the release pipeline will generate all assets and publish a GitHub Release.
|
||||
1. Tag the main branch with a full semantic-version, prefixed with a `v`. If there is a
|
||||
milestone with a partial version, the full version should be used for the git tag (e.g.
|
||||
with a Milestone of `v0.1` the tag should be `v0.1.0`). You can determine the changes going
|
||||
into a release by running `make changelog-unreleased`. Use this change list to determine the
|
||||
release increment. After determining the release increment (major, minor, patch), create the tag.
|
||||
Given the above example the command to create the tag would be `git tag v0.1.0`.
|
||||
|
||||
1. Push the tag. Given the above example the command to push the tag would be `git push origin v0.1.0`.
|
||||
|
||||
## Retracting a release
|
||||
1. A release admin must approve the release on the GitHub Actions release pipeline run page.
|
||||
Once approved, the release pipeline will generate all assets and draft a GitHub Release.
|
||||
|
||||
If a release is found to be problematic, it can be retracted with the following steps:
|
||||
|
||||
- Deleting the GitHub Release
|
||||
- Untag the docker images in the `ghcr.io` and `docker.io` registries
|
||||
- Revert the brew formula in [`anchore/homebrew-syft`](https://github.com/anchore/homebrew-syft) to point to the previous release
|
||||
- Add a new `retract` entry in the go.mod for the versioned release
|
||||
|
||||
**Note**: do not delete release tags from the git repository since there may already be references to the release
|
||||
in the go proxy, which will cause confusion when trying to reuse the tag later (the H1 hash will not match and there
|
||||
will be a warning when users try to pull the new release).
|
||||
1. Navigate to the GitHub Release draft page to review the final changelog and publish the
|
||||
release. Once published, a release-follow-up pipeline will publish derivative artifacts
|
||||
(docker image to DockerHub, brew formula to the external homebrew git repo, etc).
|
||||
|
||||
1. If there is a release Milestone, close it.
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
<!-- Use this section to tell people about which versions of your project are
|
||||
currently being supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 5.1.x | :white_check_mark: |
|
||||
| 5.0.x | :x: |
|
||||
| 4.0.x | :white_check_mark: |
|
||||
| < 4.0 | :x: |
|
||||
|
||||
-->
|
||||
|
||||
Security updates are applied only to the most recent release, try to always be up to date.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
<!-- Use this section to tell people how to report a vulnerability.
|
||||
|
||||
Tell them where to go, how often they can expect to get an update on a
|
||||
reported vulnerability, what to expect if the vulnerability is accepted or
|
||||
declined, etc. -->
|
||||
|
||||
To report a security issue, please email
|
||||
[security@anchore.com](mailto:security@anchore.com)
|
||||
with a description of the issue, the steps you took to create the issue,
|
||||
affected versions, and, if known, mitigations for the issue.
|
||||
|
||||
All support will be made on the best effort base, so please indicate the "urgency level" of the vulnerability as Critical, High, Medium or Low.
|
||||
-662
@@ -1,662 +0,0 @@
|
||||
|
||||
version: "3"
|
||||
vars:
|
||||
OWNER: anchore
|
||||
PROJECT: syft
|
||||
|
||||
CACHE_IMAGE: ghcr.io/{{ .OWNER }}/{{ .PROJECT }}/test-fixture-cache:latest
|
||||
|
||||
# static file dirs
|
||||
TOOL_DIR: .tool
|
||||
TMP_DIR: .tmp
|
||||
ORAS_CACHE: "{{ .TMP_DIR }}/oras-cache"
|
||||
CACHE_PATHS_FILE: "{{ .TMP_DIR }}/cache_paths.json"
|
||||
LAST_CACHE_PULL_FILE: "{{ .TMP_DIR }}/last_cache_paths.json"
|
||||
|
||||
# TOOLS
|
||||
ORAS: "{{ .TOOL_DIR }}/oras"
|
||||
YQ: "{{ .TOOL_DIR }}/yq"
|
||||
TASK: "{{ .TOOL_DIR }}/task"
|
||||
|
||||
# used for changelog generation
|
||||
CHANGELOG: CHANGELOG.md
|
||||
NEXT_VERSION: VERSION
|
||||
|
||||
# used for snapshot builds
|
||||
OS:
|
||||
sh: uname -s | tr '[:upper:]' '[:lower:]'
|
||||
ARCH:
|
||||
sh: |
|
||||
[ "$(uname -m)" = "x86_64" ] && echo "amd64_v1" || { [ "$(uname -m)" = "aarch64" ] && echo "arm64_v8.0" || echo $(uname -m); }
|
||||
PROJECT_ROOT:
|
||||
sh: echo $PWD
|
||||
# note: the snapshot dir must be a relative path starting with ./
|
||||
SNAPSHOT_DIR: ./snapshot
|
||||
SNAPSHOT_BIN: "{{ .PROJECT_ROOT }}/{{ .SNAPSHOT_DIR }}/{{ .OS }}-build_{{ .OS }}_{{ .ARCH }}/{{ .PROJECT }}"
|
||||
SNAPSHOT_CMD: "{{ .TOOL_DIR }}/goreleaser release --config {{ .TMP_DIR }}/goreleaser.yaml --clean --snapshot --skip=publish --skip=sign"
|
||||
BUILD_CMD: "{{ .TOOL_DIR }}/goreleaser build --config {{ .TMP_DIR }}/goreleaser.yaml --clean --snapshot --single-target"
|
||||
RELEASE_CMD: "{{ .TOOL_DIR }}/goreleaser release --clean --release-notes {{ .CHANGELOG }}"
|
||||
VERSION:
|
||||
sh: git describe --dirty --always --tags
|
||||
|
||||
# used for install and acceptance testing
|
||||
COMPARE_DIR: ./test/compare
|
||||
COMPARE_TEST_IMAGE: centos:8.2.2004
|
||||
|
||||
env:
|
||||
GNUMAKEFLAGS: '--no-print-directory'
|
||||
|
||||
tasks:
|
||||
|
||||
## High-level tasks #################################
|
||||
|
||||
default:
|
||||
desc: Run all validation tasks
|
||||
aliases:
|
||||
- pr-validations
|
||||
- validations
|
||||
cmds:
|
||||
- task: static-analysis
|
||||
- task: test
|
||||
- task: install-test
|
||||
|
||||
static-analysis:
|
||||
desc: Run all static analysis tasks
|
||||
cmds:
|
||||
- task: check-go-mod-tidy
|
||||
- task: check-licenses
|
||||
- task: lint
|
||||
- task: check-json-schema-drift
|
||||
- task: check-binary-fixture-size
|
||||
|
||||
test:
|
||||
desc: Run all levels of test
|
||||
cmds:
|
||||
- task: unit
|
||||
- task: integration
|
||||
- task: validate-cyclonedx-schema
|
||||
- task: test-utils
|
||||
- task: cli
|
||||
- task: check-docker-cache
|
||||
|
||||
## Bootstrap tasks #################################
|
||||
|
||||
binny:
|
||||
internal: true
|
||||
# desc: Get the binny tool
|
||||
generates:
|
||||
- "{{ .TOOL_DIR }}/binny"
|
||||
status:
|
||||
- "test -f {{ .TOOL_DIR }}/binny"
|
||||
cmd: "curl -sSfL https://raw.githubusercontent.com/anchore/binny/main/install.sh | sh -s -- -b .tool"
|
||||
silent: true
|
||||
|
||||
tools:
|
||||
desc: Install all tools needed for CI and local development
|
||||
deps: [binny]
|
||||
aliases:
|
||||
- bootstrap
|
||||
generates:
|
||||
- ".binny.yaml"
|
||||
- "{{ .TOOL_DIR }}/*"
|
||||
status:
|
||||
- "{{ .TOOL_DIR }}/binny check -v"
|
||||
cmd: "{{ .TOOL_DIR }}/binny install -v"
|
||||
silent: true
|
||||
|
||||
update-tools:
|
||||
desc: Update pinned versions of all tools to their latest available versions
|
||||
deps: [binny]
|
||||
generates:
|
||||
- ".binny.yaml"
|
||||
- "{{ .TOOL_DIR }}/*"
|
||||
cmd: "{{ .TOOL_DIR }}/binny update -v"
|
||||
silent: true
|
||||
|
||||
list-tools:
|
||||
desc: List all tools needed for CI and local development
|
||||
deps: [binny]
|
||||
cmd: "{{ .TOOL_DIR }}/binny list"
|
||||
silent: true
|
||||
|
||||
list-tool-updates:
|
||||
desc: List all tools that are not up to date relative to the binny config
|
||||
deps: [binny]
|
||||
cmd: "{{ .TOOL_DIR }}/binny list --updates"
|
||||
silent: true
|
||||
|
||||
tmpdir:
|
||||
silent: true
|
||||
generates:
|
||||
- "{{ .TMP_DIR }}"
|
||||
cmd: "mkdir -p {{ .TMP_DIR }}"
|
||||
|
||||
## Static analysis tasks #################################
|
||||
|
||||
format:
|
||||
desc: Auto-format all source code
|
||||
deps: [tools]
|
||||
cmds:
|
||||
- gofmt -w -s .
|
||||
- "{{ .TOOL_DIR }}/gosimports -local github.com/anchore -w ."
|
||||
- go mod tidy
|
||||
|
||||
lint-fix:
|
||||
desc: Auto-format all source code + run golangci lint fixers
|
||||
deps: [tools]
|
||||
cmds:
|
||||
- task: format
|
||||
- "{{ .TOOL_DIR }}/golangci-lint run --tests=false --fix"
|
||||
|
||||
lint:
|
||||
desc: Run gofmt + golangci lint checks
|
||||
vars:
|
||||
BAD_FMT_FILES:
|
||||
sh: gofmt -l -s .
|
||||
BAD_FILE_NAMES:
|
||||
sh: "find . | grep -e ':' || true"
|
||||
deps: [tools]
|
||||
cmds:
|
||||
# ensure there are no go fmt differences
|
||||
- cmd: 'test -z "{{ .BAD_FMT_FILES }}" || (echo "files with gofmt issues: [{{ .BAD_FMT_FILES }}]"; exit 1)'
|
||||
silent: true
|
||||
# ensure there are no files with ":" in it (a known back case in the go ecosystem)
|
||||
- cmd: 'test -z "{{ .BAD_FILE_NAMES }}" || (echo "files with bad names: [{{ .BAD_FILE_NAMES }}]"; exit 1)'
|
||||
silent: true
|
||||
# run linting
|
||||
- "{{ .TOOL_DIR }}/golangci-lint run --tests=false"
|
||||
|
||||
|
||||
check-licenses:
|
||||
# desc: Ensure transitive dependencies are compliant with the current license policy
|
||||
deps: [tools]
|
||||
cmd: "{{ .TOOL_DIR }}/bouncer check ./..."
|
||||
|
||||
check-go-mod-tidy:
|
||||
# desc: Ensure go.mod and go.sum are up to date
|
||||
cmds:
|
||||
- cmd: .github/scripts/go-mod-tidy-check.sh && echo "go.mod and go.sum are tidy!"
|
||||
silent: true
|
||||
|
||||
check-json-schema-drift:
|
||||
desc: Ensure there is no drift between the JSON schema and the code
|
||||
cmds:
|
||||
- .github/scripts/json-schema-drift-check.sh
|
||||
|
||||
check-binary-fixture-size:
|
||||
desc: Ensure that the binary test fixtures are not too large
|
||||
cmds:
|
||||
- .github/scripts/check_binary_fixture_size.sh syft/pkg/cataloger/binary/test-fixtures/classifiers/snippets
|
||||
|
||||
|
||||
## Testing tasks #################################
|
||||
update-format-golden-files:
|
||||
desc: "Update golden (i.e. snapshot) files used by unit tests"
|
||||
cmds:
|
||||
- go test ./syft/format/spdxjson -update-spdx-json
|
||||
- go test ./syft/format/spdxtagvalue -update-spdx-tv
|
||||
- go test ./syft/format/cyclonedxxml -update-cyclonedx-xml
|
||||
- go test ./syft/format/cyclonedxjson -update-cyclonedx-json
|
||||
- go test ./syft/format/syftjson -update-json
|
||||
|
||||
unit:
|
||||
desc: Run unit tests
|
||||
deps:
|
||||
- tmpdir
|
||||
- fixtures
|
||||
vars:
|
||||
TEST_PKGS:
|
||||
sh: "go list ./... | grep -v {{ .OWNER }}/{{ .PROJECT }}/test | grep -v {{ .OWNER }}/{{ .PROJECT }}/cmd/syft/internal/test | tr '\n' ' '"
|
||||
|
||||
# unit test coverage threshold (in % coverage)
|
||||
COVERAGE_THRESHOLD: 62
|
||||
cmds:
|
||||
- "go test -coverprofile {{ .TMP_DIR }}/unit-coverage-details.txt {{ .TEST_PKGS }}"
|
||||
- cmd: ".github/scripts/coverage.py {{ .COVERAGE_THRESHOLD }} {{ .TMP_DIR }}/unit-coverage-details.txt"
|
||||
silent: true
|
||||
|
||||
integration:
|
||||
desc: Run integration tests
|
||||
cmds:
|
||||
- "go test -v ./cmd/syft/internal/test/integration"
|
||||
# exercise most of the CLI with the data race detector
|
||||
# we use a larger image to ensure we're using multiple catalogers at a time
|
||||
- "go run -race cmd/syft/main.go anchore/test_images:grype-quality-dotnet-69f15d2"
|
||||
|
||||
validate-cyclonedx-schema:
|
||||
desc: Validate that Syft produces valid CycloneDX documents
|
||||
cmds:
|
||||
- "cd schema/cyclonedx && make"
|
||||
|
||||
cli:
|
||||
desc: Run CLI tests
|
||||
# note: we don't want to regenerate the snapshot unless we have to. In CI it's probable
|
||||
# that the cache being restored with the correct binary will be rebuilt since the timestamps
|
||||
# and local checksums will not line up.
|
||||
deps: [tools, snapshot]
|
||||
cmds:
|
||||
- cmd: "echo 'testing binary: {{ .SNAPSHOT_BIN }}'"
|
||||
silent: true
|
||||
- cmd: "test -f {{ .SNAPSHOT_BIN }} || (find {{ .SNAPSHOT_DIR }} && echo '\nno snapshot found for {{ .SNAPSHOT_BIN }}' && false)"
|
||||
silent: true
|
||||
|
||||
- "go test -count=1 -timeout=15m -v ./test/cli"
|
||||
env:
|
||||
SYFT_BINARY_LOCATION: "{{ .SNAPSHOT_BIN }}"
|
||||
|
||||
test-utils:
|
||||
desc: Run tests for pipeline utils
|
||||
cmds:
|
||||
- cmd: .github/scripts/labeler_test.py
|
||||
|
||||
|
||||
## Benchmark test targets #################################
|
||||
|
||||
benchmark:
|
||||
deps: [tmpdir]
|
||||
generates:
|
||||
- "{{ .TMP_DIR }}/benchmark-main.txt"
|
||||
cmds:
|
||||
- "go test -p 1 -run=^Benchmark -bench=. -count=7 -benchmem ./... | tee {{ .TMP_DIR }}/benchmark-{{ .VERSION }}.txt"
|
||||
- |
|
||||
bash -c "(test -s {{ .TMP_DIR }}/benchmark-main.txt && \
|
||||
{{ .TOOL_DIR }}/benchstat {{ .TMP_DIR }}/benchmark-main.txt {{ .TMP_DIR }}/benchmark-{{ .VERSION }}.txt || \
|
||||
{{ .TOOL_DIR }}/benchstat {{ .TMP_DIR }}/benchmark-{{ .VERSION }}.txt) \
|
||||
| tee {{ .TMP_DIR }}/benchstat.txt"
|
||||
|
||||
show-benchstat:
|
||||
deps: [benchmark, tmpdir]
|
||||
cmds:
|
||||
- cmd: "cat {{ .TMP_DIR }}/benchstat.txt"
|
||||
silent: true
|
||||
|
||||
|
||||
## Test-fixture-related targets #################################
|
||||
|
||||
fingerprints:
|
||||
desc: Generate fingerprints for all non-docker test fixture
|
||||
silent: true
|
||||
# this will look for `test-fixtures/Makefile` and invoke the `fingerprint` target to calculate all cache input fingerprint files
|
||||
generates:
|
||||
- '**/test-fixtures/**/*.fingerprint'
|
||||
- test/install/cache.fingerprint
|
||||
cmds:
|
||||
- |
|
||||
BOLD='\033[1m'
|
||||
YELLOW='\033[0;33m'
|
||||
RESET='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}creating fingerprint files for non-docker fixtures...${RESET}"
|
||||
for dir in $(find . -type d -name 'test-fixtures'); do
|
||||
if [ -f "$dir/Makefile" ]; then
|
||||
# for debugging...
|
||||
#echo -e "${YELLOW}• calculating fingerprints in $dir... ${RESET}"
|
||||
|
||||
(make -C "$dir" fingerprint)
|
||||
fi
|
||||
done
|
||||
|
||||
# for debugging...
|
||||
# echo -e "generated all fixture fingerprints"
|
||||
|
||||
- .github/scripts/fingerprint_docker_fixtures.py
|
||||
- |
|
||||
# if DOWNLOAD_TEST_FIXTURE_CACHE is set to 'false', then we don't need to calculate the fingerprint for the cache
|
||||
if [ "$DOWNLOAD_TEST_FIXTURE_CACHE" = "false" ]; then
|
||||
exit 0
|
||||
fi
|
||||
.github/scripts/find_cache_paths.py {{ .CACHE_PATHS_FILE }} > /dev/null
|
||||
|
||||
|
||||
refresh-fixtures:
|
||||
desc: Clear and fetch all test fixture cache
|
||||
aliases:
|
||||
- fixtures
|
||||
silent: true
|
||||
deps:
|
||||
- tools
|
||||
cmds:
|
||||
- |
|
||||
BOLD='\033[1m'
|
||||
PURPLE='\033[0;35m'
|
||||
RESET='\033[0m'
|
||||
|
||||
# if DOWNLOAD_TEST_FIXTURE_CACHE is set to 'false', then skip the cache download and always build
|
||||
if [ "$DOWNLOAD_TEST_FIXTURE_CACHE" = "false" ]; then
|
||||
echo -e "${BOLD}${PURPLE}skipping cache download, rebuilding cache...${RESET}"
|
||||
{{ .TASK }} build-fixtures
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LATEST_FINGERPRINT=$(docker manifest inspect {{ .CACHE_IMAGE }} | {{ .YQ }} -r '.annotations.fingerprint')
|
||||
|
||||
echo "latest cache: $LATEST_FINGERPRINT"
|
||||
|
||||
if [ -f {{ .LAST_CACHE_PULL_FILE }} ]; then
|
||||
LAST_PULL_FINGERPRINT=$(cat {{ .LAST_CACHE_PULL_FILE }} | {{ .YQ }} -r '.digest')
|
||||
else
|
||||
echo -e "${BOLD}${PURPLE}empty cache, downloading cache...${RESET}"
|
||||
{{ .TASK }} download-test-fixture-cache
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{{ .TASK }} fingerprints
|
||||
|
||||
WANT_FINGERPRINT=$(cat {{ .CACHE_PATHS_FILE }} | {{ .YQ }} -r '.digest')
|
||||
|
||||
echo "desired cache: $WANT_FINGERPRINT"
|
||||
echo "last pulled cache: $LAST_PULL_FINGERPRINT"
|
||||
|
||||
# if we already have the latest cache, skip the refresh
|
||||
if [ "$LAST_PULL_FINGERPRINT" = "$WANT_FINGERPRINT" ]; then
|
||||
echo -e "${BOLD}${PURPLE}already have the latest cache (skipping cache download)${RESET}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# at this point we only refresh the cache if we want the same cache that is currently available.
|
||||
# we don't by default refresh the cache if the cache if it is simply different from what we have,
|
||||
# because we may be working on a code change that doesn't require a cache refresh (but could trigger one,
|
||||
# which would be annoying to deal with in a development workflow).
|
||||
|
||||
if [ "$LATEST_FINGERPRINT" = "$WANT_FINGERPRINT" ]; then
|
||||
echo -e "${BOLD}${PURPLE}found newer cache! downloading cache...${RESET}"
|
||||
{{ .TASK }} download-test-fixture-cache
|
||||
else
|
||||
echo -e "${BOLD}${PURPLE}found different cache, but isn't clear if it's newer (skipping cache download and manually building)${RESET}"
|
||||
|
||||
{{ .YQ }} eval '.paths[] | "\(.digest) \(.path)"' {{ .LAST_CACHE_PULL_FILE }} > .tmp/last_cache_lines
|
||||
{{ .YQ }} eval '.paths[] | "\(.digest) \(.path)"' {{ .CACHE_PATHS_FILE }} > .tmp/cache_lines
|
||||
diff .tmp/last_cache_lines .tmp/cache_lines || true
|
||||
|
||||
echo -e "${BOLD}${PURPLE}diff with more context...${RESET}"
|
||||
|
||||
diff -U10000 {{ .LAST_CACHE_PULL_FILE }} {{ .CACHE_PATHS_FILE }} || true
|
||||
|
||||
echo -e "${BOLD}${PURPLE}detected changes to input material, manually building fixtures...${RESET}"
|
||||
|
||||
{{ .TASK }} build-fixtures
|
||||
fi
|
||||
|
||||
build-fixtures:
|
||||
desc: Generate all non-docker test fixtures
|
||||
silent: true
|
||||
# this will look for `test-fixtures/Makefile` and invoke the `fixtures` target to generate any and all test fixtures
|
||||
cmds:
|
||||
- |
|
||||
BOLD='\033[1m'
|
||||
YELLOW='\033[0;33m'
|
||||
RESET='\033[0m'
|
||||
|
||||
# Use a for loop with command substitution to avoid subshell issues
|
||||
for dir in $(find . -type d -name 'test-fixtures'); do
|
||||
if [ -f "$dir/Makefile" ]; then
|
||||
echo -e "${YELLOW}${BOLD}generating fixtures in $dir${RESET}"
|
||||
(make -C "$dir" fixtures)
|
||||
fi
|
||||
done
|
||||
echo -e "${BOLD}generated all fixtures${RESET}"
|
||||
|
||||
download-test-fixture-cache:
|
||||
desc: Download test fixture cache from ghcr.io
|
||||
deps: [tools, clean-cache]
|
||||
vars:
|
||||
CACHE_DIGEST:
|
||||
sh: docker manifest inspect {{ .CACHE_IMAGE }} | {{ .YQ }} -r '.annotations.fingerprint'
|
||||
cmds:
|
||||
- silent: true
|
||||
cmd: |
|
||||
# if oras cache is > 4 GB, delete it
|
||||
if [ -d {{ .ORAS_CACHE }} ]; then
|
||||
total_size=$(du -c {{ .ORAS_CACHE }} | grep total | awk '{print $1}')
|
||||
if [ "$total_size" -gt 4194304 ]; then
|
||||
echo 'deleting oras cache'
|
||||
rm -rf {{ .ORAS_CACHE }}
|
||||
fi
|
||||
fi
|
||||
- "ORAS_CACHE={{ .ORAS_CACHE }} {{ .ORAS }} pull {{ .CACHE_IMAGE }}"
|
||||
- "cp {{ .CACHE_PATHS_FILE }} {{ .LAST_CACHE_PULL_FILE }}"
|
||||
|
||||
upload-test-fixture-cache:
|
||||
desc: Upload the test fixture cache to ghcr.io
|
||||
deps: [tools, fingerprints]
|
||||
silent: true
|
||||
cmd: |
|
||||
set -eu
|
||||
oras_command="{{ .ORAS }} push {{ .CACHE_IMAGE }}"
|
||||
|
||||
paths=$(cat {{ .CACHE_PATHS_FILE }} | {{ .YQ }} -r '.paths[].path')
|
||||
for path in $paths; do
|
||||
oras_command+=" $path"
|
||||
done
|
||||
oras_command+=" {{ .CACHE_PATHS_FILE }}"
|
||||
|
||||
oras_command+=" --annotation org.opencontainers.image.source=https://github.com/{{ .OWNER }}/{{ .PROJECT }}"
|
||||
oras_command+=" --annotation fingerprint=$(cat {{ .CACHE_PATHS_FILE }} | {{ .YQ }} -r '.digest')"
|
||||
|
||||
echo "Executing: $oras_command"
|
||||
eval $oras_command
|
||||
|
||||
show-test-image-cache:
|
||||
silent: true
|
||||
cmds:
|
||||
- "echo 'Docker daemon cache:'"
|
||||
- "docker images --format '{{`{{.ID}}`}} {{`{{.Repository}}`}}:{{`{{.Tag}}`}}' | grep stereoscope-fixture- | sort"
|
||||
- "echo '\nTar cache:'"
|
||||
- 'find . -type f -wholename "**/test-fixtures/cache/stereoscope-fixture-*.tar" | sort'
|
||||
|
||||
check-docker-cache:
|
||||
desc: Ensure docker caches aren't using too much disk space
|
||||
silent: true
|
||||
cmd: |
|
||||
total_size=$(find . | grep cache | grep tar | xargs du -c | grep total | awk '{print $1}')
|
||||
find . | grep cache | grep tar | xargs du
|
||||
echo "total $total_size KB"
|
||||
|
||||
if [ "$total_size" -gt 5242880 ]; then
|
||||
echo 'docker cache is larger than 5GB'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
## install.sh testing targets #################################
|
||||
|
||||
install-test:
|
||||
cmds:
|
||||
- "cd test/install && make"
|
||||
|
||||
install-test-cache-save:
|
||||
cmds:
|
||||
- "cd test/install && make save"
|
||||
|
||||
install-test-cache-load:
|
||||
cmds:
|
||||
- "cd test/install && make load"
|
||||
|
||||
install-test-ci-mac:
|
||||
cmds:
|
||||
- "cd test/install && make ci-test-mac"
|
||||
|
||||
generate-compare-file:
|
||||
cmd: "go run ./cmd/syft {{ .COMPARE_TEST_IMAGE }} -o json > {{ .COMPARE_DIR }}/test-fixtures/acceptance-{{ .COMPARE_TEST_IMAGE }}.json"
|
||||
|
||||
compare-mac:
|
||||
deps: [tmpdir]
|
||||
cmd: |
|
||||
{{ .COMPARE_DIR }}/mac.sh \
|
||||
{{ .SNAPSHOT_DIR }} \
|
||||
{{ .COMPARE_DIR }} \
|
||||
{{ .COMPARE_TEST_IMAGE }} \
|
||||
{{ .TMP_DIR }}
|
||||
|
||||
compare-linux:
|
||||
cmds:
|
||||
- task: compare-test-deb-package-install
|
||||
- task: compare-test-rpm-package-install
|
||||
|
||||
compare-test-deb-package-install:
|
||||
deps: [tmpdir]
|
||||
cmd: |
|
||||
{{ .COMPARE_DIR }}/deb.sh \
|
||||
{{ .SNAPSHOT_DIR }} \
|
||||
{{ .COMPARE_DIR }} \
|
||||
{{ .COMPARE_TEST_IMAGE }} \
|
||||
{{ .TMP_DIR }}
|
||||
|
||||
compare-test-rpm-package-install:
|
||||
deps: [tmpdir]
|
||||
cmd: |
|
||||
{{ .COMPARE_DIR }}/rpm.sh \
|
||||
{{ .SNAPSHOT_DIR }} \
|
||||
{{ .COMPARE_DIR }} \
|
||||
{{ .COMPARE_TEST_IMAGE }} \
|
||||
{{ .TMP_DIR }}
|
||||
|
||||
|
||||
## Code and data generation targets #################################
|
||||
|
||||
generate:
|
||||
desc: Add data generation tasks
|
||||
cmds:
|
||||
- task: generate-json-schema
|
||||
- task: generate-license-list
|
||||
- task: generate-cpe-dictionary-index
|
||||
|
||||
generate-json-schema:
|
||||
desc: Generate a new JSON schema
|
||||
cmds:
|
||||
- "cd syft/internal && go generate . && cd jsonschema && go run . && go fmt ../..."
|
||||
|
||||
generate-license-list:
|
||||
desc: Generate an updated license processing code off of the latest available SPDX license list
|
||||
cmds:
|
||||
- "go generate ./internal/spdxlicense/..."
|
||||
- "gofmt -s -w ./internal/spdxlicense"
|
||||
|
||||
generate-cpe-dictionary-index:
|
||||
desc: Generate the CPE index based off of the latest available CPE dictionary
|
||||
dir: "syft/pkg/cataloger/internal/cpegenerate/dictionary"
|
||||
cmds:
|
||||
- "go generate"
|
||||
|
||||
|
||||
## Build-related targets #################################
|
||||
|
||||
build:
|
||||
desc: Build the project
|
||||
deps: [tools, tmpdir]
|
||||
generates:
|
||||
- "{{ .PROJECT }}"
|
||||
cmds:
|
||||
- silent: true
|
||||
cmd: |
|
||||
echo "dist: {{ .SNAPSHOT_DIR }}" > {{ .TMP_DIR }}/goreleaser.yaml
|
||||
cat .goreleaser.yaml >> {{ .TMP_DIR }}/goreleaser.yaml
|
||||
|
||||
- "{{ .BUILD_CMD }}"
|
||||
|
||||
snapshot:
|
||||
desc: Create a snapshot release
|
||||
aliases:
|
||||
- build
|
||||
deps: [tools, tmpdir]
|
||||
sources:
|
||||
- cmd/**/*.go
|
||||
- syft/**/*.go
|
||||
- internal/**/*.go
|
||||
method: checksum
|
||||
generates:
|
||||
- "{{ .SNAPSHOT_BIN }}"
|
||||
cmds:
|
||||
- silent: true
|
||||
cmd: |
|
||||
echo "dist: {{ .SNAPSHOT_DIR }}" > {{ .TMP_DIR }}/goreleaser.yaml
|
||||
cat .goreleaser.yaml >> {{ .TMP_DIR }}/goreleaser.yaml
|
||||
|
||||
- "{{ .SNAPSHOT_CMD }}"
|
||||
|
||||
changelog:
|
||||
desc: Generate a changelog
|
||||
deps: [tools]
|
||||
generates:
|
||||
- "{{ .CHANGELOG }}"
|
||||
- "{{ .NEXT_VERSION }}"
|
||||
cmds:
|
||||
- "{{ .TOOL_DIR }}/chronicle -vv -n --version-file {{ .NEXT_VERSION }} > {{ .CHANGELOG }}"
|
||||
- "{{ .TOOL_DIR }}/glow -w 0 {{ .CHANGELOG }}"
|
||||
|
||||
|
||||
## Release targets #################################
|
||||
|
||||
release:
|
||||
desc: Create a release
|
||||
interactive: true
|
||||
deps: [tools]
|
||||
cmds:
|
||||
- cmd: .github/scripts/trigger-release.sh
|
||||
silent: true
|
||||
|
||||
|
||||
## CI-only targets #################################
|
||||
|
||||
ci-check:
|
||||
# desc: "[CI only] Are you in CI?"
|
||||
cmds:
|
||||
- cmd: .github/scripts/ci-check.sh
|
||||
silent: true
|
||||
|
||||
ci-release:
|
||||
# desc: "[CI only] Create a release"
|
||||
deps: [tools]
|
||||
cmds:
|
||||
- task: ci-check
|
||||
- "{{ .TOOL_DIR }}/chronicle -vvv > CHANGELOG.md"
|
||||
- cmd: "cat CHANGELOG.md"
|
||||
silent: true
|
||||
- "{{ .RELEASE_CMD }}"
|
||||
|
||||
ci-release-version-file:
|
||||
# desc: "[CI only] Update the version file"
|
||||
deps: [tools]
|
||||
cmds:
|
||||
- task: ci-check
|
||||
- ".github/scripts/update-version-file.sh {{ .RELEASE_VERSION }}"
|
||||
requires:
|
||||
vars:
|
||||
- RELEASE_VERSION
|
||||
|
||||
|
||||
## Cleanup targets #################################
|
||||
|
||||
clean-snapshot:
|
||||
desc: Remove any snapshot builds
|
||||
cmds:
|
||||
- "rm -rf {{ .SNAPSHOT_DIR }}"
|
||||
- "rm -rf {{ .TMP_DIR }}/goreleaser.yaml"
|
||||
|
||||
clean-docker-cache:
|
||||
desc: Remove all docker cache tars and images from the daemon
|
||||
cmds:
|
||||
- find . -type d -wholename "**/test-fixtures/cache" | xargs rm -rf
|
||||
- docker images --format '{{`{{.ID}}`}} {{`{{.Repository}}`}}' | grep stereoscope-fixture- | awk '{print $1}' | uniq | xargs -r docker rmi --force
|
||||
|
||||
clean-oras-cache:
|
||||
desc: Remove all cache for oras commands
|
||||
cmd: rm -rf {{ .ORAS_CACHE }}
|
||||
|
||||
clean-cache:
|
||||
desc: Remove all image docker tar cache, images from the docker daemon, and ephemeral test fixtures
|
||||
cmds:
|
||||
- task: clean-docker-cache
|
||||
- |
|
||||
BOLD='\033[1m'
|
||||
YELLOW='\033[0;33m'
|
||||
RESET='\033[0m'
|
||||
|
||||
# Use a for loop with command substitution to avoid subshell issues
|
||||
for dir in $(find . -type d -name 'test-fixtures'); do
|
||||
if [ -f "$dir/Makefile" ]; then
|
||||
echo -e "${YELLOW}${BOLD}deleting ephemeral test fixtures in $dir${RESET}"
|
||||
(make -C "$dir" clean)
|
||||
fi
|
||||
done
|
||||
echo -e "${BOLD}Deleted all ephemeral test fixtures${RESET}"
|
||||
- rm -f {{ .LAST_CACHE_PULL_FILE }} {{ .CACHE_PATHS_FILE }}
|
||||
@@ -1,5 +0,0 @@
|
||||
# See documentation here: https://github.com/artifacthub/hub/blob/v1.6.0/docs/metadata/artifacthub-repo.yml
|
||||
repositoryID: eced152f-b15d-4879-8b3b-1175397192ba
|
||||
owners:
|
||||
- name: wagoodman
|
||||
email: wagoodman@gmail.com
|
||||
@@ -0,0 +1,31 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/bus"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/version"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
)
|
||||
|
||||
func checkForApplicationUpdate() {
|
||||
if appConfig.CheckForAppUpdate {
|
||||
log.Debugf("checking if new vesion of %s is available", internal.ApplicationName)
|
||||
isAvailable, newVersion, err := version.IsUpdateAvailable()
|
||||
if err != nil {
|
||||
// this should never stop the application
|
||||
log.Errorf(err.Error())
|
||||
}
|
||||
if isAvailable {
|
||||
log.Infof("new version of %s is available: %s (current version is %s)", internal.ApplicationName, newVersion, version.FromBuild().Version)
|
||||
|
||||
bus.Publish(partybus.Event{
|
||||
Type: event.AppUpdateAvailable,
|
||||
Value: newVersion,
|
||||
})
|
||||
} else {
|
||||
log.Debugf("no new %s update available", internal.ApplicationName)
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/anchore/go-logger/logrus"
|
||||
"github.com/anchore/stereoscope"
|
||||
"github.com/anchore/syft/internal/config"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/version"
|
||||
"github.com/anchore/syft/syft"
|
||||
"github.com/gookit/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
)
|
||||
|
||||
var (
|
||||
appConfig *config.Application
|
||||
eventBus *partybus.Bus
|
||||
eventSubscription *partybus.Subscription
|
||||
)
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(
|
||||
initCmdAliasBindings,
|
||||
initAppConfig,
|
||||
initLogging,
|
||||
logAppConfig,
|
||||
checkForApplicationUpdate,
|
||||
logAppVersion,
|
||||
initEventBus,
|
||||
)
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, color.Red.Sprint(err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// we must setup the config-cli bindings first before the application configuration is parsed. However, this cannot
|
||||
// be done without determining what the primary command that the config options should be bound to since there are
|
||||
// shared concerns (the root-packages alias).
|
||||
func initCmdAliasBindings() {
|
||||
activeCmd, _, err := rootCmd.Find(os.Args[1:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// enable all cataloger by default if power-user command is run
|
||||
if activeCmd == powerUserCmd {
|
||||
config.PowerUserCatalogerEnabledDefault()
|
||||
}
|
||||
|
||||
switch activeCmd {
|
||||
case packagesCmd, rootCmd:
|
||||
// note: we need to lazily bind config options since they are shared between both the root command
|
||||
// and the packages command. Otherwise there will be global viper state that is in contention.
|
||||
// See for more details: https://github.com/spf13/viper/issues/233 . Additionally, the bindings must occur BEFORE
|
||||
// reading the application configuration, which implies that it must be an initializer (or rewrite the command
|
||||
// initialization structure against typical patterns used with cobra, which is somewhat extreme for a
|
||||
// temporary alias)
|
||||
if err = bindPackagesConfigOptions(activeCmd.Flags()); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
default:
|
||||
// even though the root command or packages command is NOT being run, we still need default bindings
|
||||
// such that application config parsing passes.
|
||||
if err = bindPackagesConfigOptions(packagesCmd.Flags()); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func initAppConfig() {
|
||||
cfg, err := config.LoadApplicationConfig(viper.GetViper(), persistentOpts)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to load application config: \n\t%+v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appConfig = cfg
|
||||
}
|
||||
|
||||
func initLogging() {
|
||||
cfg := logrus.Config{
|
||||
EnableConsole: (appConfig.Log.FileLocation == "" || appConfig.CliOptions.Verbosity > 0) && !appConfig.Quiet,
|
||||
EnableFile: appConfig.Log.FileLocation != "",
|
||||
Level: appConfig.Log.LevelOpt,
|
||||
Structured: appConfig.Log.Structured,
|
||||
FileLocation: appConfig.Log.FileLocation,
|
||||
}
|
||||
|
||||
logger, err := logrus.New(cfg)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
syft.SetLogger(logger)
|
||||
stereoscope.SetLogger(logger.Nested("from-lib", "stereoscope"))
|
||||
}
|
||||
|
||||
func logAppConfig() {
|
||||
log.Debugf("application config:\n%+v", color.Magenta.Sprint(appConfig.String()))
|
||||
}
|
||||
|
||||
func initEventBus() {
|
||||
eventBus = partybus.NewBus()
|
||||
eventSubscription = eventBus.Subscribe()
|
||||
|
||||
stereoscope.SetBus(eventBus)
|
||||
syft.SetBus(eventBus)
|
||||
}
|
||||
|
||||
func logAppVersion() {
|
||||
versionInfo := version.FromBuild()
|
||||
log.Infof("syft version: %s", versionInfo.Version)
|
||||
|
||||
var fields map[string]interface{}
|
||||
bytes, err := json.Marshal(versionInfo)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(bytes, &fields)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for idx, field := range keys {
|
||||
value := fields[field]
|
||||
branch := "├──"
|
||||
if idx == len(fields)-1 {
|
||||
branch = "└──"
|
||||
}
|
||||
log.Debugf(" %s %s: %s", branch, field, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/client"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// completionCmd represents the completion command
|
||||
var completionCmd = &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish]",
|
||||
Short: "Generate a shell completion for Syft (listing local docker images)",
|
||||
Long: `To load completions (docker image list):
|
||||
|
||||
Bash:
|
||||
|
||||
$ source <(syft completion bash)
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
Linux:
|
||||
$ syft completion bash > /etc/bash_completion.d/syft
|
||||
MacOS:
|
||||
$ syft completion bash > /usr/local/etc/bash_completion.d/syft
|
||||
|
||||
Zsh:
|
||||
|
||||
# If shell completion is not already enabled in your environment you will need
|
||||
# to enable it. You can execute the following once:
|
||||
|
||||
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
$ syft completion zsh > "${fpath[1]}/_syft"
|
||||
|
||||
# You will need to start a new shell for this setup to take effect.
|
||||
|
||||
Fish:
|
||||
|
||||
$ syft completion fish | source
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
$ syft completion fish > ~/.config/fish/completions/syft.fish
|
||||
`,
|
||||
DisableFlagsInUseLine: true,
|
||||
ValidArgs: []string{"bash", "zsh", "fish"},
|
||||
Args: cobra.ExactValidArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var err error
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
err = cmd.Root().GenBashCompletion(os.Stdout)
|
||||
case "zsh":
|
||||
err = cmd.Root().GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
err = cmd.Root().GenFishCompletion(os.Stdout, true)
|
||||
}
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(completionCmd)
|
||||
}
|
||||
|
||||
func dockerImageValidArgsFunction(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
// Since we use ValidArgsFunction, Cobra will call this AFTER having parsed all flags and arguments provided
|
||||
dockerImageRepoTags, err := listLocalDockerImages(toComplete)
|
||||
if err != nil {
|
||||
// Indicates that an error occurred and completions should be ignored
|
||||
return []string{"completion failed"}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
if len(dockerImageRepoTags) == 0 {
|
||||
return []string{"no docker images found"}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
// ShellCompDirectiveDefault indicates that the shell will perform its default behavior after completions have
|
||||
// been provided (without implying other possible directives)
|
||||
return dockerImageRepoTags, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
|
||||
func listLocalDockerImages(prefix string) ([]string, error) {
|
||||
var repoTags = make([]string, 0)
|
||||
ctx := context.Background()
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return repoTags, err
|
||||
}
|
||||
|
||||
// Only want to return tagged images
|
||||
imageListArgs := filters.NewArgs()
|
||||
imageListArgs.Add("dangling", "false")
|
||||
images, err := cli.ImageList(ctx, types.ImageListOptions{All: false, Filters: imageListArgs})
|
||||
if err != nil {
|
||||
return repoTags, err
|
||||
}
|
||||
|
||||
for _, image := range images {
|
||||
// image may have multiple tags
|
||||
for _, tag := range image.RepoTags {
|
||||
if strings.HasPrefix(tag, prefix) {
|
||||
repoTags = append(repoTags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
return repoTags, nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/ui"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
)
|
||||
|
||||
// eventLoop listens to worker errors (from execution path), worker events (from a partybus subscription), and
|
||||
// signal interrupts. Is responsible for handling each event relative to a given UI an to coordinate eventing until
|
||||
// an eventual graceful exit.
|
||||
// nolint:funlen
|
||||
func eventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *partybus.Subscription, cleanupFn func(), uxs ...ui.UI) error {
|
||||
defer cleanupFn()
|
||||
events := subscription.Events()
|
||||
var err error
|
||||
var ux ui.UI
|
||||
|
||||
if ux, err = setupUI(subscription.Unsubscribe, uxs...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var retErr error
|
||||
var forceTeardown bool
|
||||
|
||||
for {
|
||||
if workerErrs == nil && events == nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case err, isOpen := <-workerErrs:
|
||||
if !isOpen {
|
||||
workerErrs = nil
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
// capture the error from the worker and unsubscribe to complete a graceful shutdown
|
||||
retErr = multierror.Append(retErr, err)
|
||||
_ = subscription.Unsubscribe()
|
||||
// the worker has exited, we may have been mid-handling events for the UI which should now be
|
||||
// ignored, in which case forcing a teardown of the UI irregardless of the state is required.
|
||||
forceTeardown = true
|
||||
}
|
||||
case e, isOpen := <-events:
|
||||
if !isOpen {
|
||||
events = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if err := ux.Handle(e); err != nil {
|
||||
if errors.Is(err, partybus.ErrUnsubscribe) {
|
||||
events = nil
|
||||
} else {
|
||||
retErr = multierror.Append(retErr, err)
|
||||
// TODO: should we unsubscribe? should we try to halt execution? or continue?
|
||||
}
|
||||
}
|
||||
case <-signals:
|
||||
// ignore further results from any event source and exit ASAP, but ensure that all cache is cleaned up.
|
||||
// we ignore further errors since cleaning up the tmp directories will affect running catalogers that are
|
||||
// reading/writing from/to their nested temp dirs. This is acceptable since we are bailing without result.
|
||||
|
||||
// TODO: potential future improvement would be to pass context into workers with a cancel function that is
|
||||
// to the event loop. In this way we can have a more controlled shutdown even at the most nested levels
|
||||
// of processing.
|
||||
events = nil
|
||||
workerErrs = nil
|
||||
forceTeardown = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := ux.Teardown(forceTeardown); err != nil {
|
||||
retErr = multierror.Append(retErr, err)
|
||||
}
|
||||
|
||||
return retErr
|
||||
}
|
||||
|
||||
// setupUI takes one or more UIs that responds to events and takes a event bus unsubscribe function for use
|
||||
// during teardown. With the given UIs, the first UI which the ui.Setup() function does not return an error
|
||||
// will be utilized in execution. Providing a set of UIs allows for the caller to provide graceful fallbacks
|
||||
// when there are environmental problem (e.g. unable to setup a TUI with the current TTY).
|
||||
func setupUI(unsubscribe func() error, uis ...ui.UI) (ui.UI, error) {
|
||||
for _, ux := range uis {
|
||||
if err := ux.Setup(unsubscribe); err != nil {
|
||||
log.Warnf("unable to setup given UI, falling back to alternative UI: %+v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
return ux, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unable to setup any UI")
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/anchore/syft/internal/ui"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
)
|
||||
|
||||
var _ ui.UI = (*uiMock)(nil)
|
||||
|
||||
type uiMock struct {
|
||||
t *testing.T
|
||||
finalEvent partybus.Event
|
||||
unsubscribe func() error
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (u *uiMock) Setup(unsubscribe func() error) error {
|
||||
u.t.Logf("UI Setup called")
|
||||
u.unsubscribe = unsubscribe
|
||||
return u.Called(unsubscribe).Error(0)
|
||||
}
|
||||
|
||||
func (u *uiMock) Handle(event partybus.Event) error {
|
||||
u.t.Logf("UI Handle called: %+v", event.Type)
|
||||
if event == u.finalEvent {
|
||||
assert.NoError(u.t, u.unsubscribe())
|
||||
}
|
||||
return u.Called(event).Error(0)
|
||||
}
|
||||
|
||||
func (u *uiMock) Teardown(_ bool) error {
|
||||
u.t.Logf("UI Teardown called")
|
||||
return u.Called().Error(0)
|
||||
}
|
||||
|
||||
func Test_eventLoop_gracefulExit(t *testing.T) {
|
||||
test := func(t *testing.T) {
|
||||
|
||||
testBus := partybus.NewBus()
|
||||
subscription := testBus.Subscribe()
|
||||
t.Cleanup(testBus.Close)
|
||||
|
||||
finalEvent := partybus.Event{
|
||||
Type: event.Exit,
|
||||
}
|
||||
|
||||
worker := func() <-chan error {
|
||||
ret := make(chan error)
|
||||
go func() {
|
||||
t.Log("worker running")
|
||||
// send an empty item (which is ignored) ensuring we've entered the select statement,
|
||||
// then close (a partial shutdown).
|
||||
ret <- nil
|
||||
t.Log("worker sent nothing")
|
||||
close(ret)
|
||||
t.Log("worker closed")
|
||||
// do the other half of the shutdown
|
||||
testBus.Publish(finalEvent)
|
||||
t.Log("worker published final event")
|
||||
}()
|
||||
return ret
|
||||
}
|
||||
|
||||
signaler := func() <-chan os.Signal {
|
||||
return nil
|
||||
}
|
||||
|
||||
ux := &uiMock{
|
||||
t: t,
|
||||
finalEvent: finalEvent,
|
||||
}
|
||||
|
||||
// ensure the mock sees at least the final event
|
||||
ux.On("Handle", finalEvent).Return(nil)
|
||||
// ensure the mock sees basic setup/teardown events
|
||||
ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
|
||||
ux.On("Teardown").Return(nil)
|
||||
|
||||
var cleanupCalled bool
|
||||
cleanupFn := func() {
|
||||
t.Log("cleanup called")
|
||||
cleanupCalled = true
|
||||
}
|
||||
|
||||
assert.NoError(t,
|
||||
eventLoop(
|
||||
worker(),
|
||||
signaler(),
|
||||
subscription,
|
||||
cleanupFn,
|
||||
ux,
|
||||
),
|
||||
)
|
||||
|
||||
assert.True(t, cleanupCalled, "cleanup function not called")
|
||||
ux.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// if there is a bug, then there is a risk of the event loop never returning
|
||||
testWithTimeout(t, 5*time.Second, test)
|
||||
}
|
||||
|
||||
func Test_eventLoop_workerError(t *testing.T) {
|
||||
test := func(t *testing.T) {
|
||||
|
||||
testBus := partybus.NewBus()
|
||||
subscription := testBus.Subscribe()
|
||||
t.Cleanup(testBus.Close)
|
||||
|
||||
workerErr := fmt.Errorf("worker error")
|
||||
|
||||
worker := func() <-chan error {
|
||||
ret := make(chan error)
|
||||
go func() {
|
||||
t.Log("worker running")
|
||||
// send an empty item (which is ignored) ensuring we've entered the select statement,
|
||||
// then close (a partial shutdown).
|
||||
ret <- nil
|
||||
t.Log("worker sent nothing")
|
||||
ret <- workerErr
|
||||
t.Log("worker sent error")
|
||||
close(ret)
|
||||
t.Log("worker closed")
|
||||
// note: NO final event is fired
|
||||
}()
|
||||
return ret
|
||||
}
|
||||
|
||||
signaler := func() <-chan os.Signal {
|
||||
return nil
|
||||
}
|
||||
|
||||
ux := &uiMock{
|
||||
t: t,
|
||||
}
|
||||
|
||||
// ensure the mock sees basic setup/teardown events
|
||||
ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
|
||||
ux.On("Teardown").Return(nil)
|
||||
|
||||
var cleanupCalled bool
|
||||
cleanupFn := func() {
|
||||
t.Log("cleanup called")
|
||||
cleanupCalled = true
|
||||
}
|
||||
|
||||
// ensure we see an error returned
|
||||
assert.ErrorIs(t,
|
||||
eventLoop(
|
||||
worker(),
|
||||
signaler(),
|
||||
subscription,
|
||||
cleanupFn,
|
||||
ux,
|
||||
),
|
||||
workerErr,
|
||||
"should have seen a worker error, but did not",
|
||||
)
|
||||
|
||||
assert.True(t, cleanupCalled, "cleanup function not called")
|
||||
ux.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// if there is a bug, then there is a risk of the event loop never returning
|
||||
testWithTimeout(t, 5*time.Second, test)
|
||||
}
|
||||
|
||||
func Test_eventLoop_unsubscribeError(t *testing.T) {
|
||||
test := func(t *testing.T) {
|
||||
|
||||
testBus := partybus.NewBus()
|
||||
subscription := testBus.Subscribe()
|
||||
t.Cleanup(testBus.Close)
|
||||
|
||||
finalEvent := partybus.Event{
|
||||
Type: event.Exit,
|
||||
}
|
||||
|
||||
worker := func() <-chan error {
|
||||
ret := make(chan error)
|
||||
go func() {
|
||||
t.Log("worker running")
|
||||
// send an empty item (which is ignored) ensuring we've entered the select statement,
|
||||
// then close (a partial shutdown).
|
||||
ret <- nil
|
||||
t.Log("worker sent nothing")
|
||||
close(ret)
|
||||
t.Log("worker closed")
|
||||
// do the other half of the shutdown
|
||||
testBus.Publish(finalEvent)
|
||||
t.Log("worker published final event")
|
||||
}()
|
||||
return ret
|
||||
}
|
||||
|
||||
signaler := func() <-chan os.Signal {
|
||||
return nil
|
||||
}
|
||||
|
||||
ux := &uiMock{
|
||||
t: t,
|
||||
finalEvent: finalEvent,
|
||||
}
|
||||
|
||||
// ensure the mock sees at least the final event... note the unsubscribe error here
|
||||
ux.On("Handle", finalEvent).Return(partybus.ErrUnsubscribe)
|
||||
// ensure the mock sees basic setup/teardown events
|
||||
ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
|
||||
ux.On("Teardown").Return(nil)
|
||||
|
||||
var cleanupCalled bool
|
||||
cleanupFn := func() {
|
||||
t.Log("cleanup called")
|
||||
cleanupCalled = true
|
||||
}
|
||||
|
||||
// unsubscribe errors should be handled and ignored, not propagated. We are additionally asserting that
|
||||
// this case is handled as a controlled shutdown (this test should not timeout)
|
||||
assert.NoError(t,
|
||||
eventLoop(
|
||||
worker(),
|
||||
signaler(),
|
||||
subscription,
|
||||
cleanupFn,
|
||||
ux,
|
||||
),
|
||||
)
|
||||
|
||||
assert.True(t, cleanupCalled, "cleanup function not called")
|
||||
ux.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// if there is a bug, then there is a risk of the event loop never returning
|
||||
testWithTimeout(t, 5*time.Second, test)
|
||||
}
|
||||
|
||||
func Test_eventLoop_handlerError(t *testing.T) {
|
||||
test := func(t *testing.T) {
|
||||
|
||||
testBus := partybus.NewBus()
|
||||
subscription := testBus.Subscribe()
|
||||
t.Cleanup(testBus.Close)
|
||||
|
||||
finalEvent := partybus.Event{
|
||||
Type: event.Exit,
|
||||
Error: fmt.Errorf("an exit error occured"),
|
||||
}
|
||||
|
||||
worker := func() <-chan error {
|
||||
ret := make(chan error)
|
||||
go func() {
|
||||
t.Log("worker running")
|
||||
// send an empty item (which is ignored) ensuring we've entered the select statement,
|
||||
// then close (a partial shutdown).
|
||||
ret <- nil
|
||||
t.Log("worker sent nothing")
|
||||
close(ret)
|
||||
t.Log("worker closed")
|
||||
// do the other half of the shutdown
|
||||
testBus.Publish(finalEvent)
|
||||
t.Log("worker published final event")
|
||||
}()
|
||||
return ret
|
||||
}
|
||||
|
||||
signaler := func() <-chan os.Signal {
|
||||
return nil
|
||||
}
|
||||
|
||||
ux := &uiMock{
|
||||
t: t,
|
||||
finalEvent: finalEvent,
|
||||
}
|
||||
|
||||
// ensure the mock sees at least the final event... note the event error is propagated
|
||||
ux.On("Handle", finalEvent).Return(finalEvent.Error)
|
||||
// ensure the mock sees basic setup/teardown events
|
||||
ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
|
||||
ux.On("Teardown").Return(nil)
|
||||
|
||||
var cleanupCalled bool
|
||||
cleanupFn := func() {
|
||||
t.Log("cleanup called")
|
||||
cleanupCalled = true
|
||||
}
|
||||
|
||||
// handle errors SHOULD propagate the event loop. We are additionally asserting that this case is
|
||||
// handled as a controlled shutdown (this test should not timeout)
|
||||
assert.ErrorIs(t,
|
||||
eventLoop(
|
||||
worker(),
|
||||
signaler(),
|
||||
subscription,
|
||||
cleanupFn,
|
||||
ux,
|
||||
),
|
||||
finalEvent.Error,
|
||||
"should have seen a event error, but did not",
|
||||
)
|
||||
|
||||
assert.True(t, cleanupCalled, "cleanup function not called")
|
||||
ux.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// if there is a bug, then there is a risk of the event loop never returning
|
||||
testWithTimeout(t, 5*time.Second, test)
|
||||
}
|
||||
|
||||
func Test_eventLoop_signalsStopExecution(t *testing.T) {
|
||||
test := func(t *testing.T) {
|
||||
|
||||
testBus := partybus.NewBus()
|
||||
subscription := testBus.Subscribe()
|
||||
t.Cleanup(testBus.Close)
|
||||
|
||||
worker := func() <-chan error {
|
||||
// the worker will never return work and the event loop will always be waiting...
|
||||
return make(chan error)
|
||||
}
|
||||
|
||||
signaler := func() <-chan os.Signal {
|
||||
ret := make(chan os.Signal)
|
||||
go func() {
|
||||
ret <- syscall.SIGINT
|
||||
// note: we do NOT close the channel to ensure the event loop does not depend on that behavior to exit
|
||||
}()
|
||||
return ret
|
||||
}
|
||||
|
||||
ux := &uiMock{
|
||||
t: t,
|
||||
}
|
||||
|
||||
// ensure the mock sees basic setup/teardown events
|
||||
ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
|
||||
ux.On("Teardown").Return(nil)
|
||||
|
||||
var cleanupCalled bool
|
||||
cleanupFn := func() {
|
||||
t.Log("cleanup called")
|
||||
cleanupCalled = true
|
||||
}
|
||||
|
||||
assert.NoError(t,
|
||||
eventLoop(
|
||||
worker(),
|
||||
signaler(),
|
||||
subscription,
|
||||
cleanupFn,
|
||||
ux,
|
||||
),
|
||||
)
|
||||
|
||||
assert.True(t, cleanupCalled, "cleanup function not called")
|
||||
ux.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// if there is a bug, then there is a risk of the event loop never returning
|
||||
testWithTimeout(t, 5*time.Second, test)
|
||||
}
|
||||
|
||||
func Test_eventLoop_uiTeardownError(t *testing.T) {
|
||||
test := func(t *testing.T) {
|
||||
|
||||
testBus := partybus.NewBus()
|
||||
subscription := testBus.Subscribe()
|
||||
t.Cleanup(testBus.Close)
|
||||
|
||||
finalEvent := partybus.Event{
|
||||
Type: event.Exit,
|
||||
}
|
||||
|
||||
worker := func() <-chan error {
|
||||
ret := make(chan error)
|
||||
go func() {
|
||||
t.Log("worker running")
|
||||
// send an empty item (which is ignored) ensuring we've entered the select statement,
|
||||
// then close (a partial shutdown).
|
||||
ret <- nil
|
||||
t.Log("worker sent nothing")
|
||||
close(ret)
|
||||
t.Log("worker closed")
|
||||
// do the other half of the shutdown
|
||||
testBus.Publish(finalEvent)
|
||||
t.Log("worker published final event")
|
||||
}()
|
||||
return ret
|
||||
}
|
||||
|
||||
signaler := func() <-chan os.Signal {
|
||||
return nil
|
||||
}
|
||||
|
||||
ux := &uiMock{
|
||||
t: t,
|
||||
finalEvent: finalEvent,
|
||||
}
|
||||
|
||||
teardownError := fmt.Errorf("sorry, dave, the UI doesn't want to be torn down")
|
||||
|
||||
// ensure the mock sees at least the final event... note the event error is propagated
|
||||
ux.On("Handle", finalEvent).Return(nil)
|
||||
// ensure the mock sees basic setup/teardown events
|
||||
ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
|
||||
ux.On("Teardown").Return(teardownError)
|
||||
|
||||
var cleanupCalled bool
|
||||
cleanupFn := func() {
|
||||
t.Log("cleanup called")
|
||||
cleanupCalled = true
|
||||
}
|
||||
|
||||
// ensure we see an error returned
|
||||
assert.ErrorIs(t,
|
||||
eventLoop(
|
||||
worker(),
|
||||
signaler(),
|
||||
subscription,
|
||||
cleanupFn,
|
||||
ux,
|
||||
),
|
||||
teardownError,
|
||||
"should have seen a UI teardown error, but did not",
|
||||
)
|
||||
|
||||
assert.True(t, cleanupCalled, "cleanup function not called")
|
||||
ux.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// if there is a bug, then there is a risk of the event loop never returning
|
||||
testWithTimeout(t, 5*time.Second, test)
|
||||
}
|
||||
|
||||
func testWithTimeout(t *testing.T, timeout time.Duration, test func(*testing.T)) {
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
test(t)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(timeout):
|
||||
t.Fatal("test timed out")
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/syft/internal/formats"
|
||||
"github.com/anchore/syft/internal/output"
|
||||
"github.com/anchore/syft/syft/format"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
)
|
||||
|
||||
// makeWriter creates a sbom.Writer for output or returns an error. this will either return a valid writer
|
||||
// or an error but neither both and if there is no error, sbom.Writer.Close() should be called
|
||||
func makeWriter(outputs []string, defaultFile string) (sbom.Writer, error) {
|
||||
outputOptions, err := parseOptions(outputs, defaultFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
writer, err := output.MakeWriter(outputOptions...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return writer, nil
|
||||
}
|
||||
|
||||
// parseOptions utility to parse command-line option strings and retain the existing behavior of default format and file
|
||||
func parseOptions(outputs []string, defaultFile string) (out []output.WriterOption, errs error) {
|
||||
// always should have one option -- we generally get the default of "table", but just make sure
|
||||
if len(outputs) == 0 {
|
||||
outputs = append(outputs, string(format.TableOption))
|
||||
}
|
||||
|
||||
for _, name := range outputs {
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
// split to at most two parts for <format>=<file>
|
||||
parts := strings.SplitN(name, "=", 2)
|
||||
|
||||
// the format option is the first part
|
||||
name = parts[0]
|
||||
|
||||
// default to the --file or empty string if not specified
|
||||
file := defaultFile
|
||||
|
||||
// If a file is specified as part of the output option, use that
|
||||
if len(parts) > 1 {
|
||||
file = parts[1]
|
||||
}
|
||||
|
||||
option := format.ParseOption(name)
|
||||
if option == format.UnknownFormatOption {
|
||||
errs = multierror.Append(errs, fmt.Errorf("bad output format: '%s'", name))
|
||||
continue
|
||||
}
|
||||
|
||||
encoder := formats.ByOption(option)
|
||||
if encoder == nil {
|
||||
errs = multierror.Append(errs, fmt.Errorf("unknown format: %s", outputFormat))
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, output.WriterOption{
|
||||
Format: *encoder,
|
||||
Path: file,
|
||||
})
|
||||
}
|
||||
return out, errs
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestOutputWriterConfig(t *testing.T) {
|
||||
tmp := t.TempDir() + "/"
|
||||
|
||||
tests := []struct {
|
||||
outputs []string
|
||||
file string
|
||||
err bool
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
outputs: []string{},
|
||||
expected: []string{""},
|
||||
},
|
||||
{
|
||||
outputs: []string{"json"},
|
||||
expected: []string{""},
|
||||
},
|
||||
{
|
||||
file: "test-1.json",
|
||||
expected: []string{"test-1.json"},
|
||||
},
|
||||
{
|
||||
outputs: []string{"json=test-2.json"},
|
||||
expected: []string{"test-2.json"},
|
||||
},
|
||||
{
|
||||
outputs: []string{"json=test-3-1.json", "spdx-json=test-3-2.json"},
|
||||
expected: []string{"test-3-1.json", "test-3-2.json"},
|
||||
},
|
||||
{
|
||||
outputs: []string{"text", "json=test-4.json"},
|
||||
expected: []string{"", "test-4.json"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("%s/%s", test.outputs, test.file), func(t *testing.T) {
|
||||
outputs := test.outputs
|
||||
for i, val := range outputs {
|
||||
outputs[i] = strings.Replace(val, "=", "="+tmp, 1)
|
||||
}
|
||||
|
||||
file := test.file
|
||||
if file != "" {
|
||||
file = tmp + file
|
||||
}
|
||||
|
||||
_, err := makeWriter(test.outputs, file)
|
||||
|
||||
if test.err {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
for _, expected := range test.expected {
|
||||
if expected != "" {
|
||||
assert.FileExists(t, tmp+expected)
|
||||
} else if file != "" {
|
||||
assert.FileExists(t, file)
|
||||
} else {
|
||||
assert.NoFileExists(t, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"github.com/anchore/stereoscope"
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/anchore"
|
||||
"github.com/anchore/syft/internal/bus"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/ui"
|
||||
"github.com/anchore/syft/internal/version"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
"github.com/anchore/syft/syft/format"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
"github.com/pkg/profile"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
)
|
||||
|
||||
const (
|
||||
packagesExample = ` {{.appName}} {{.command}} alpine:latest a summary of discovered packages
|
||||
{{.appName}} {{.command}} alpine:latest -o json show all possible cataloging details
|
||||
{{.appName}} {{.command}} alpine:latest -o cyclonedx show a CycloneDX formatted SBOM
|
||||
{{.appName}} {{.command}} alpine:latest -o spdx show a SPDX 2.2 tag-value formatted SBOM
|
||||
{{.appName}} {{.command}} alpine:latest -o spdx-json show a SPDX 2.2 JSON formatted SBOM
|
||||
{{.appName}} {{.command}} alpine:latest -vv show verbose debug information
|
||||
|
||||
Supports the following image sources:
|
||||
{{.appName}} {{.command}} yourrepo/yourimage:tag defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry.
|
||||
{{.appName}} {{.command}} path/to/a/file/or/dir a Docker tar, OCI tar, OCI directory, or generic filesystem directory
|
||||
|
||||
You can also explicitly specify the scheme to use:
|
||||
{{.appName}} {{.command}} docker:yourrepo/yourimage:tag explicitly use the Docker daemon
|
||||
{{.appName}} {{.command}} docker-archive:path/to/yourimage.tar use a tarball from disk for archives created from "docker save"
|
||||
{{.appName}} {{.command}} oci-archive:path/to/yourimage.tar use a tarball from disk for OCI archives (from Skopeo or otherwise)
|
||||
{{.appName}} {{.command}} oci-dir:path/to/yourimage read directly from a path on disk for OCI layout directories (from Skopeo or otherwise)
|
||||
{{.appName}} {{.command}} dir:path/to/yourproject read directly from a path on disk (any directory)
|
||||
{{.appName}} {{.command}} file:path/to/yourproject/file read directly from a path on disk (any single file)
|
||||
{{.appName}} {{.command}} registry:yourrepo/yourimage:tag pull image directly from a registry (no container runtime required)
|
||||
`
|
||||
)
|
||||
|
||||
var (
|
||||
packagesCmd = &cobra.Command{
|
||||
Use: "packages [SOURCE]",
|
||||
Short: "Generate a package SBOM",
|
||||
Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from container images and filesystems",
|
||||
Example: internal.Tprintf(packagesExample, map[string]interface{}{
|
||||
"appName": internal.ApplicationName,
|
||||
"command": "packages",
|
||||
}),
|
||||
Args: validateInputArgs,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) (err error) {
|
||||
if appConfig.Dev.ProfileCPU && appConfig.Dev.ProfileMem {
|
||||
return fmt.Errorf("cannot profile CPU and memory simultaneously")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if appConfig.Dev.ProfileCPU {
|
||||
defer profile.Start(profile.CPUProfile).Stop()
|
||||
} else if appConfig.Dev.ProfileMem {
|
||||
defer profile.Start(profile.MemProfile).Stop()
|
||||
}
|
||||
|
||||
return packagesExec(cmd, args)
|
||||
},
|
||||
ValidArgsFunction: dockerImageValidArgsFunction,
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
setPackageFlags(packagesCmd.Flags())
|
||||
|
||||
rootCmd.AddCommand(packagesCmd)
|
||||
}
|
||||
|
||||
func setPackageFlags(flags *pflag.FlagSet) {
|
||||
// Formatting & Input options //////////////////////////////////////////////
|
||||
|
||||
flags.StringP(
|
||||
"scope", "s", cataloger.DefaultSearchConfig().Scope.String(),
|
||||
fmt.Sprintf("selection of layers to catalog, options=%v", source.AllScopes))
|
||||
|
||||
flags.StringArrayP(
|
||||
"output", "o", []string{string(format.TableOption)},
|
||||
fmt.Sprintf("report output format, options=%v", format.AllOptions),
|
||||
)
|
||||
|
||||
flags.StringP(
|
||||
"file", "", "",
|
||||
"file to write the default report output to (default is STDOUT)",
|
||||
)
|
||||
|
||||
// Upload options //////////////////////////////////////////////////////////
|
||||
flags.StringP(
|
||||
"host", "H", "",
|
||||
"the hostname or URL of the Anchore Enterprise instance to upload to",
|
||||
)
|
||||
|
||||
flags.StringP(
|
||||
"username", "u", "",
|
||||
"the username to authenticate against Anchore Enterprise",
|
||||
)
|
||||
|
||||
flags.StringP(
|
||||
"password", "p", "",
|
||||
"the password to authenticate against Anchore Enterprise",
|
||||
)
|
||||
|
||||
flags.StringP(
|
||||
"dockerfile", "d", "",
|
||||
"include dockerfile for upload to Anchore Enterprise",
|
||||
)
|
||||
|
||||
flags.StringArrayP(
|
||||
"exclude", "", nil,
|
||||
"exclude paths from being scanned using a glob expression",
|
||||
)
|
||||
|
||||
flags.Bool(
|
||||
"overwrite-existing-image", false,
|
||||
"overwrite an existing image during the upload to Anchore Enterprise",
|
||||
)
|
||||
|
||||
flags.Uint(
|
||||
"import-timeout", 30,
|
||||
"set a timeout duration (in seconds) for the upload to Anchore Enterprise",
|
||||
)
|
||||
}
|
||||
|
||||
func bindPackagesConfigOptions(flags *pflag.FlagSet) error {
|
||||
// Formatting & Input options //////////////////////////////////////////////
|
||||
|
||||
if err := viper.BindPFlag("package.cataloger.scope", flags.Lookup("scope")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("output", flags.Lookup("output")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("file", flags.Lookup("file")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("exclude", flags.Lookup("exclude")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Upload options //////////////////////////////////////////////////////////
|
||||
|
||||
if err := viper.BindPFlag("anchore.host", flags.Lookup("host")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("anchore.username", flags.Lookup("username")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("anchore.password", flags.Lookup("password")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("anchore.dockerfile", flags.Lookup("dockerfile")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("anchore.overwrite-existing-image", flags.Lookup("overwrite-existing-image")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("anchore.import-timeout", flags.Lookup("import-timeout")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInputArgs(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
// in the case that no arguments are given we want to show the help text and return with a non-0 return code.
|
||||
if err := cmd.Help(); err != nil {
|
||||
return fmt.Errorf("unable to display help: %w", err)
|
||||
}
|
||||
return fmt.Errorf("an image/directory argument is required")
|
||||
}
|
||||
|
||||
return cobra.MaximumNArgs(1)(cmd, args)
|
||||
}
|
||||
|
||||
func packagesExec(_ *cobra.Command, args []string) error {
|
||||
writer, err := makeWriter(appConfig.Output, appConfig.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := writer.Close(); err != nil {
|
||||
log.Warnf("unable to write to report destination: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// could be an image or a directory, with or without a scheme
|
||||
userInput := args[0]
|
||||
|
||||
return eventLoop(
|
||||
packagesExecWorker(userInput, writer),
|
||||
setupSignals(),
|
||||
eventSubscription,
|
||||
stereoscope.Cleanup,
|
||||
ui.Select(isVerbose(), appConfig.Quiet)...,
|
||||
)
|
||||
}
|
||||
|
||||
func isVerbose() (result bool) {
|
||||
isPipedInput, err := internal.IsPipedInput()
|
||||
if err != nil {
|
||||
// since we can't tell if there was piped input we assume that there could be to disable the ETUI
|
||||
log.Warnf("unable to determine if there is piped input: %+v", err)
|
||||
return true
|
||||
}
|
||||
// verbosity should consider if there is piped input (in which case we should not show the ETUI)
|
||||
return appConfig.CliOptions.Verbosity > 0 || isPipedInput
|
||||
}
|
||||
|
||||
func packagesExecWorker(userInput string, writer sbom.Writer) <-chan error {
|
||||
errs := make(chan error)
|
||||
go func() {
|
||||
defer close(errs)
|
||||
|
||||
tasks, err := tasks()
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
|
||||
src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions(), appConfig.Exclusions)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("failed to construct source from user input %q: %w", userInput, err)
|
||||
return
|
||||
}
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
s := sbom.SBOM{
|
||||
Source: src.Metadata,
|
||||
Descriptor: sbom.Descriptor{
|
||||
Name: internal.ApplicationName,
|
||||
Version: version.FromBuild().Version,
|
||||
Configuration: appConfig,
|
||||
},
|
||||
}
|
||||
|
||||
var relationships []<-chan artifact.Relationship
|
||||
for _, task := range tasks {
|
||||
c := make(chan artifact.Relationship)
|
||||
relationships = append(relationships, c)
|
||||
|
||||
go runTask(task, &s.Artifacts, src, c, errs)
|
||||
}
|
||||
s.Relationships = append(s.Relationships, mergeRelationships(relationships...)...)
|
||||
|
||||
if appConfig.Anchore.Host != "" {
|
||||
if err := runPackageSbomUpload(src, s); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
bus.Publish(partybus.Event{
|
||||
Type: event.Exit,
|
||||
Value: func() error { return writer.Write(s) },
|
||||
})
|
||||
}()
|
||||
return errs
|
||||
}
|
||||
|
||||
func mergeRelationships(cs ...<-chan artifact.Relationship) (relationships []artifact.Relationship) {
|
||||
for _, c := range cs {
|
||||
for n := range c {
|
||||
relationships = append(relationships, n)
|
||||
}
|
||||
}
|
||||
|
||||
return relationships
|
||||
}
|
||||
|
||||
func runPackageSbomUpload(src *source.Source, s sbom.SBOM) error {
|
||||
log.Infof("uploading results to %s", appConfig.Anchore.Host)
|
||||
|
||||
if src.Metadata.Scheme != source.ImageScheme {
|
||||
return fmt.Errorf("unable to upload results: only images are supported")
|
||||
}
|
||||
|
||||
var dockerfileContents []byte
|
||||
if appConfig.Anchore.Dockerfile != "" {
|
||||
if _, err := os.Stat(appConfig.Anchore.Dockerfile); os.IsNotExist(err) {
|
||||
return fmt.Errorf("unable dockerfile=%q does not exist: %w", appConfig.Anchore.Dockerfile, err)
|
||||
}
|
||||
|
||||
fh, err := os.Open(appConfig.Anchore.Dockerfile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to open dockerfile=%q: %w", appConfig.Anchore.Dockerfile, err)
|
||||
}
|
||||
|
||||
dockerfileContents, err = ioutil.ReadAll(fh)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read dockerfile=%q: %w", appConfig.Anchore.Dockerfile, err)
|
||||
}
|
||||
}
|
||||
|
||||
c, err := anchore.NewClient(anchore.Configuration{
|
||||
BaseURL: appConfig.Anchore.Host,
|
||||
Username: appConfig.Anchore.Username,
|
||||
Password: appConfig.Anchore.Password,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create anchore client: %w", err)
|
||||
}
|
||||
|
||||
importCfg := anchore.ImportConfig{
|
||||
ImageMetadata: src.Image.Metadata,
|
||||
SBOM: s,
|
||||
Dockerfile: dockerfileContents,
|
||||
OverwriteExistingUpload: appConfig.Anchore.OverwriteExistingImage,
|
||||
Timeout: appConfig.Anchore.ImportTimeout,
|
||||
}
|
||||
|
||||
if err := c.Import(context.Background(), importCfg); err != nil {
|
||||
return fmt.Errorf("failed to upload results to host=%s: %+v", appConfig.Anchore.Host, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/anchore/stereoscope"
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/bus"
|
||||
"github.com/anchore/syft/internal/formats/syftjson"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/output"
|
||||
"github.com/anchore/syft/internal/ui"
|
||||
"github.com/anchore/syft/internal/version"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
"github.com/gookit/color"
|
||||
"github.com/pkg/profile"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
)
|
||||
|
||||
const powerUserExample = ` {{.appName}} {{.command}} <image>
|
||||
|
||||
DEPRECATED - THIS COMMAND WILL BE REMOVED in v1.0.0
|
||||
|
||||
Only image sources are supported (e.g. docker: , docker-archive: , oci: , etc.), the directory source (dir:) is not supported.
|
||||
|
||||
All behavior is controlled via application configuration and environment variables (see https://github.com/anchore/syft#configuration)
|
||||
`
|
||||
|
||||
var powerUserOpts = struct {
|
||||
configPath string
|
||||
}{}
|
||||
|
||||
var powerUserCmd = &cobra.Command{
|
||||
Use: "power-user [IMAGE]",
|
||||
Short: "Run bulk operations on container images",
|
||||
Example: internal.Tprintf(powerUserExample, map[string]interface{}{
|
||||
"appName": internal.ApplicationName,
|
||||
"command": "power-user",
|
||||
}),
|
||||
Args: validateInputArgs,
|
||||
Hidden: true,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if appConfig.Dev.ProfileCPU && appConfig.Dev.ProfileMem {
|
||||
return fmt.Errorf("cannot profile CPU and memory simultaneously")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if appConfig.Dev.ProfileCPU {
|
||||
defer profile.Start(profile.CPUProfile).Stop()
|
||||
} else if appConfig.Dev.ProfileMem {
|
||||
defer profile.Start(profile.MemProfile).Stop()
|
||||
}
|
||||
|
||||
return powerUserExec(cmd, args)
|
||||
},
|
||||
ValidArgsFunction: dockerImageValidArgsFunction,
|
||||
}
|
||||
|
||||
func init() {
|
||||
powerUserCmd.Flags().StringVarP(&powerUserOpts.configPath, "config", "c", "", "config file path with all power-user options")
|
||||
|
||||
rootCmd.AddCommand(powerUserCmd)
|
||||
}
|
||||
|
||||
func powerUserExec(_ *cobra.Command, args []string) error {
|
||||
// could be an image or a directory, with or without a scheme
|
||||
userInput := args[0]
|
||||
|
||||
writer, err := output.MakeWriter(output.WriterOption{
|
||||
Format: syftjson.Format(),
|
||||
Path: appConfig.File,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := writer.Close(); err != nil {
|
||||
log.Warnf("unable to write to report destination: %+v", err)
|
||||
}
|
||||
|
||||
// inform user at end of run that command will be removed
|
||||
deprecated := color.Style{color.Red, color.OpBold}.Sprint("DEPRECATED: This command will be removed in v1.0.0")
|
||||
fmt.Fprintln(os.Stderr, deprecated)
|
||||
}()
|
||||
|
||||
return eventLoop(
|
||||
powerUserExecWorker(userInput, writer),
|
||||
setupSignals(),
|
||||
eventSubscription,
|
||||
stereoscope.Cleanup,
|
||||
ui.Select(isVerbose(), appConfig.Quiet)...,
|
||||
)
|
||||
}
|
||||
func powerUserExecWorker(userInput string, writer sbom.Writer) <-chan error {
|
||||
errs := make(chan error)
|
||||
go func() {
|
||||
defer close(errs)
|
||||
|
||||
appConfig.Secrets.Cataloger.Enabled = true
|
||||
appConfig.FileMetadata.Cataloger.Enabled = true
|
||||
appConfig.FileContents.Cataloger.Enabled = true
|
||||
appConfig.FileClassification.Cataloger.Enabled = true
|
||||
tasks, err := tasks()
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
|
||||
src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions(), appConfig.Exclusions)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
s := sbom.SBOM{
|
||||
Source: src.Metadata,
|
||||
Descriptor: sbom.Descriptor{
|
||||
Name: internal.ApplicationName,
|
||||
Version: version.FromBuild().Version,
|
||||
Configuration: appConfig,
|
||||
},
|
||||
}
|
||||
|
||||
var relationships []<-chan artifact.Relationship
|
||||
for _, task := range tasks {
|
||||
c := make(chan artifact.Relationship)
|
||||
relationships = append(relationships, c)
|
||||
|
||||
go runTask(task, &s.Artifacts, src, c, errs)
|
||||
}
|
||||
|
||||
s.Relationships = append(s.Relationships, mergeRelationships(relationships...)...)
|
||||
|
||||
bus.Publish(partybus.Event{
|
||||
Type: event.Exit,
|
||||
Value: func() error { return writer.Write(s) },
|
||||
})
|
||||
}()
|
||||
|
||||
return errs
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/config"
|
||||
"github.com/anchore/syft/internal/version"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var persistentOpts = config.CliOnlyOptions{}
|
||||
|
||||
// rootCmd is currently an alias for the packages command
|
||||
var rootCmd = &cobra.Command{
|
||||
Short: packagesCmd.Short,
|
||||
Long: packagesCmd.Long,
|
||||
Args: packagesCmd.Args,
|
||||
Example: packagesCmd.Example,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PreRunE: packagesCmd.PreRunE,
|
||||
RunE: packagesCmd.RunE,
|
||||
ValidArgsFunction: packagesCmd.ValidArgsFunction,
|
||||
Version: version.FromBuild().Version,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// set universal flags
|
||||
rootCmd.PersistentFlags().StringVarP(&persistentOpts.ConfigPath, "config", "c", "", "application config file")
|
||||
// setting the version template to just print out the string since we already have a templatized version string
|
||||
rootCmd.SetVersionTemplate(fmt.Sprintf("%s {{.Version}}\n", internal.ApplicationName))
|
||||
flag := "quiet"
|
||||
rootCmd.PersistentFlags().BoolP(
|
||||
flag, "q", false,
|
||||
"suppress all logging output",
|
||||
)
|
||||
|
||||
if err := viper.BindPFlag(flag, rootCmd.PersistentFlags().Lookup(flag)); err != nil {
|
||||
fmt.Printf("unable to bind flag '%s': %+v", flag, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().CountVarP(&persistentOpts.Verbosity, "verbose", "v", "increase verbosity (-v = info, -vv = debug)")
|
||||
|
||||
// set common options that are not universal (package subcommand-alias specific)
|
||||
setPackageFlags(rootCmd.Flags())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func setupSignals() <-chan os.Signal {
|
||||
c := make(chan os.Signal, 1) // Note: A buffered channel is recommended for this; see https://golang.org/pkg/os/signal/#Notify
|
||||
|
||||
interruptions := []os.Signal{
|
||||
syscall.SIGINT,
|
||||
syscall.SIGTERM,
|
||||
}
|
||||
|
||||
signal.Notify(c, interruptions...)
|
||||
|
||||
return c
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
cranecmd "github.com/google/go-containerregistry/cmd/crane/cmd"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/cmd/syft/internal"
|
||||
"github.com/anchore/syft/cmd/syft/internal/commands"
|
||||
)
|
||||
|
||||
// Application constructs the `syft packages` command and aliases the root command to `syft packages`.
|
||||
// It is also responsible for organizing flag usage and injecting the application config for each command.
|
||||
// It also constructs the syft attest command and the syft version command.
|
||||
// `RunE` is the earliest that the complete application configuration can be loaded.
|
||||
func Application(id clio.Identification) clio.Application {
|
||||
app, _ := create(id, os.Stdout)
|
||||
return app
|
||||
}
|
||||
|
||||
// Command returns the root command for the syft CLI application. This is useful for embedding the entire syft CLI
|
||||
// into an existing application.
|
||||
func Command(id clio.Identification) *cobra.Command {
|
||||
_, cmd := create(id, os.Stdout)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func create(id clio.Identification, out io.Writer) (clio.Application, *cobra.Command) {
|
||||
clioCfg := internal.AppClioSetupConfig(id, out)
|
||||
|
||||
app := clio.New(*clioCfg)
|
||||
|
||||
// since root is aliased as the packages cmd we need to construct this command first
|
||||
// we also need the command to have information about the `root` options because of this alias
|
||||
scanCmd := commands.Scan(app)
|
||||
|
||||
// root is currently an alias for the scan command
|
||||
rootCmd := commands.Root(app, scanCmd)
|
||||
|
||||
// add sub-commands
|
||||
rootCmd.AddCommand(
|
||||
scanCmd,
|
||||
commands.Packages(app, scanCmd), // this is currently an alias for the scan command
|
||||
commands.Cataloger(app),
|
||||
commands.Attest(app),
|
||||
commands.Convert(app),
|
||||
clio.VersionCommand(id),
|
||||
clio.ConfigCommand(app, nil),
|
||||
cranecmd.NewCmdAuthLogin(id.Name), // syft login uses the same command as crane
|
||||
)
|
||||
|
||||
// note: we would direct cobra to use our writer explicitly with rootCmd.SetOut(out) , however this causes
|
||||
// deprecation warnings to be shown to stdout via the writer instead of stderr. This is unfortunate since this
|
||||
// does not appear to be the correct behavior on cobra's part https://github.com/spf13/cobra/issues/1708 .
|
||||
// In the future this functionality should be restored.
|
||||
|
||||
return app, rootCmd
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
|
||||
[TestHandler_handleAttestationStarted/attesting_in_progress/task_line - 1]
|
||||
⠋ Creating a thing running a thing
|
||||
---
|
||||
|
||||
[TestHandler_handleAttestationStarted/attesting_in_progress/log - 1]
|
||||
░░ contents
|
||||
░░ of
|
||||
░░ stuff!
|
||||
|
||||
---
|
||||
|
||||
[TestHandler_handleAttestationStarted/attesting_complete/task_line - 1]
|
||||
✔ Created a thing running a thing
|
||||
---
|
||||
|
||||
[TestHandler_handleAttestationStarted/attesting_complete/log - 1]
|
||||
|
||||
---
|
||||
@@ -1,24 +0,0 @@
|
||||
|
||||
[TestHandler_handleCatalogerTaskStarted/cataloging_task_in_progress - 1]
|
||||
⠙ Cataloging contents
|
||||
⠙ some task title ━━━━━━━━━━━━━━━━━━━━ [some stage]
|
||||
---
|
||||
|
||||
[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_in_progress - 1]
|
||||
⠙ Cataloging contents
|
||||
└── ⠙ some task title ━━━━━━━━━━━━━━━━━━━━ [some stage]
|
||||
---
|
||||
|
||||
[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_complete - 1]
|
||||
⠙ Cataloging contents
|
||||
└── ✔ some task done [some stage]
|
||||
---
|
||||
|
||||
[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_complete_--_hide_stage - 1]
|
||||
⠙ Cataloging contents
|
||||
└── ✔ some task done
|
||||
---
|
||||
|
||||
[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_complete_with_removal - 1]
|
||||
⠙ Cataloging contents
|
||||
---
|
||||
@@ -1,8 +0,0 @@
|
||||
|
||||
[TestHandler_handleFetchImage/fetch_image_in_progress - 1]
|
||||
⠋ Loading image ━━━━━━━━━━━━━━━━━━━━ [current] the-image
|
||||
---
|
||||
|
||||
[TestHandler_handleFetchImage/fetch_image_complete - 1]
|
||||
✔ Loaded image the-image
|
||||
---
|
||||
@@ -1,8 +0,0 @@
|
||||
|
||||
[TestHandler_handleFileIndexingStarted/cataloging_in_progress - 1]
|
||||
⠋ Indexing file system ━━━━━━━━━━━━━━━━━━━━ [current] /some/path
|
||||
---
|
||||
|
||||
[TestHandler_handleFileIndexingStarted/cataloging_complete - 1]
|
||||
✔ Indexed file system /some/path
|
||||
---
|
||||
@@ -1,12 +0,0 @@
|
||||
|
||||
[Test_dockerPullStatusFormatter_Render/pulling - 1]
|
||||
3 Layers▕▅▃ ▏[12 B / 30 B]
|
||||
---
|
||||
|
||||
[Test_dockerPullStatusFormatter_Render/download_complete - 1]
|
||||
3 Layers▕█▃ ▏[30 B] Extracting...
|
||||
---
|
||||
|
||||
[Test_dockerPullStatusFormatter_Render/complete - 1]
|
||||
3 Layers▕███▏[30 B] Extracting...
|
||||
---
|
||||
@@ -1,8 +0,0 @@
|
||||
|
||||
[TestHandler_handleReadImage/read_image_in_progress - 1]
|
||||
⠋ Parsing image ━━━━━━━━━━━━━━━━━━━━ id
|
||||
---
|
||||
|
||||
[TestHandler_handleReadImage/read_image_complete - 1]
|
||||
✔ Parsed image id
|
||||
---
|
||||
@@ -1,255 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/google/uuid"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
"github.com/wagoodman/go-progress"
|
||||
"github.com/zyedidia/generic/queue"
|
||||
|
||||
"github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
syftEventParsers "github.com/anchore/syft/syft/event/parsers"
|
||||
)
|
||||
|
||||
var (
|
||||
_ tea.Model = (*attestLogFrame)(nil)
|
||||
)
|
||||
|
||||
type attestLogFrame struct {
|
||||
reader *backgroundLineReader
|
||||
prog progress.Progressable
|
||||
lines []string
|
||||
completed bool
|
||||
failed bool
|
||||
windowSize tea.WindowSizeMsg
|
||||
|
||||
id uint32
|
||||
sequence int
|
||||
|
||||
updateDuration time.Duration
|
||||
borderStype lipgloss.Style
|
||||
}
|
||||
|
||||
// attestLogFrameTickMsg indicates that the timer has ticked and we should render a frame.
|
||||
type attestLogFrameTickMsg struct {
|
||||
Time time.Time
|
||||
Sequence int
|
||||
ID uint32
|
||||
}
|
||||
|
||||
type backgroundLineReader struct {
|
||||
limit int
|
||||
lines *queue.Queue[string]
|
||||
lock *sync.RWMutex
|
||||
|
||||
// This is added specifically for tests to assert when the background reader is done.
|
||||
// The main UI uses the global ui wait group from the handler to otherwise block
|
||||
// Shared concerns among multiple model made it difficult to test using the global wait group
|
||||
// so this is added to allow tests to assert when the background reader is done.
|
||||
running *sync.WaitGroup
|
||||
}
|
||||
|
||||
func (m *Handler) handleAttestationStarted(e partybus.Event) []tea.Model {
|
||||
reader, prog, taskInfo, err := syftEventParsers.ParseAttestationStartedEvent(e)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Debug("unable to parse event")
|
||||
return nil
|
||||
}
|
||||
|
||||
stage := progress.Stage{}
|
||||
|
||||
tsk := m.newTaskProgress(
|
||||
taskprogress.Title{
|
||||
Default: taskInfo.Title.Default,
|
||||
Running: taskInfo.Title.WhileRunning,
|
||||
Success: taskInfo.Title.OnSuccess,
|
||||
},
|
||||
taskprogress.WithStagedProgressable(
|
||||
struct {
|
||||
progress.Progressable
|
||||
progress.Stager
|
||||
}{
|
||||
Progressable: prog,
|
||||
Stager: &stage,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
tsk.HideStageOnSuccess = false
|
||||
|
||||
if taskInfo.Context != "" {
|
||||
tsk.Context = []string{taskInfo.Context}
|
||||
}
|
||||
|
||||
borderStyle := tsk.HintStyle
|
||||
|
||||
return []tea.Model{
|
||||
tsk,
|
||||
newLogFrame(newBackgroundLineReader(m.Running, reader, &stage), prog, borderStyle),
|
||||
}
|
||||
}
|
||||
|
||||
func newLogFrame(reader *backgroundLineReader, prog progress.Progressable, borderStyle lipgloss.Style) attestLogFrame {
|
||||
return attestLogFrame{
|
||||
reader: reader,
|
||||
prog: prog,
|
||||
id: uuid.Must(uuid.NewUUID()).ID(),
|
||||
updateDuration: 250 * time.Millisecond,
|
||||
borderStype: borderStyle,
|
||||
}
|
||||
}
|
||||
|
||||
func newBackgroundLineReader(wg *sync.WaitGroup, reader io.Reader, stage *progress.Stage) *backgroundLineReader {
|
||||
r := &backgroundLineReader{
|
||||
limit: 7,
|
||||
lock: &sync.RWMutex{},
|
||||
lines: queue.New[string](),
|
||||
running: &sync.WaitGroup{},
|
||||
}
|
||||
|
||||
// tracks the background reader for the global handler wait group
|
||||
wg.Add(1)
|
||||
|
||||
// tracks the background reader for the local wait group (used in tests to decouple from the global handler wait group)
|
||||
r.running.Add(1)
|
||||
|
||||
go func() {
|
||||
r.read(reader, stage)
|
||||
wg.Done()
|
||||
r.running.Done()
|
||||
}()
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (l *backgroundLineReader) read(reader io.Reader, stage *progress.Stage) {
|
||||
s := bufio.NewScanner(reader)
|
||||
|
||||
for s.Scan() {
|
||||
l.lock.Lock()
|
||||
|
||||
text := s.Text()
|
||||
l.lines.Enqueue(text)
|
||||
|
||||
if strings.Contains(text, "tlog entry created with index") {
|
||||
fields := strings.SplitN(text, ":", 2)
|
||||
present := text
|
||||
if len(fields) == 2 {
|
||||
present = fmt.Sprintf("transparency log index: %s", fields[1])
|
||||
}
|
||||
stage.Current = present
|
||||
} else if strings.Contains(text, "WARNING: skipping transparency log upload") {
|
||||
stage.Current = "transparency log upload skipped"
|
||||
}
|
||||
|
||||
// only show the last X lines of the shell output
|
||||
for l.lines.Len() > l.limit {
|
||||
l.lines.Dequeue()
|
||||
}
|
||||
|
||||
l.lock.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (l backgroundLineReader) Lines() []string {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
var lines []string
|
||||
|
||||
l.lines.Each(func(line string) {
|
||||
lines = append(lines, line)
|
||||
})
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func (l attestLogFrame) Init() tea.Cmd {
|
||||
// this is the periodic update of state information
|
||||
return func() tea.Msg {
|
||||
return attestLogFrameTickMsg{
|
||||
// The time at which the tick occurred.
|
||||
Time: time.Now(),
|
||||
|
||||
// The ID of the log frame that this message belongs to. This can be
|
||||
// helpful when routing messages, however bear in mind that log frames
|
||||
// will ignore messages that don't contain ID by default.
|
||||
ID: l.id,
|
||||
|
||||
Sequence: l.sequence,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l attestLogFrame) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
l.windowSize = msg
|
||||
return l, nil
|
||||
|
||||
case attestLogFrameTickMsg:
|
||||
l.lines = l.reader.Lines()
|
||||
|
||||
l.completed = progress.IsCompleted(l.prog)
|
||||
err := l.prog.Error()
|
||||
l.failed = err != nil && !progress.IsErrCompleted(err)
|
||||
|
||||
tickCmd := l.handleTick(msg)
|
||||
|
||||
return l, tickCmd
|
||||
}
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l attestLogFrame) View() string {
|
||||
if l.completed && !l.failed {
|
||||
return ""
|
||||
}
|
||||
|
||||
sb := strings.Builder{}
|
||||
|
||||
for _, line := range l.lines {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s\n", l.borderStype.Render("░░"), line))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (l attestLogFrame) queueNextTick() tea.Cmd {
|
||||
return tea.Tick(l.updateDuration, func(t time.Time) tea.Msg {
|
||||
return attestLogFrameTickMsg{
|
||||
Time: t,
|
||||
ID: l.id,
|
||||
Sequence: l.sequence,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (l *attestLogFrame) handleTick(msg attestLogFrameTickMsg) tea.Cmd {
|
||||
// If an ID is set, and the ID doesn't belong to this log frame, reject the message.
|
||||
if msg.ID > 0 && msg.ID != l.id {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If a sequence is set, and it's not the one we expect, reject the message.
|
||||
// This prevents the log frame from receiving too many messages and
|
||||
// thus updating too frequently.
|
||||
if msg.Sequence > 0 && msg.Sequence != l.sequence {
|
||||
return nil
|
||||
}
|
||||
|
||||
l.sequence++
|
||||
|
||||
// note: even if the log is completed we should still respond to stage changes and window size events
|
||||
return l.queueNextTick()
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/gkampitakis/go-snaps/snaps"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
"github.com/wagoodman/go-progress"
|
||||
|
||||
"github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
syftEvent "github.com/anchore/syft/syft/event"
|
||||
"github.com/anchore/syft/syft/event/monitor"
|
||||
)
|
||||
|
||||
func TestHandler_handleAttestationStarted(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
eventFn func(*testing.T) partybus.Event
|
||||
iterations int
|
||||
}{
|
||||
{
|
||||
name: "attesting in progress",
|
||||
// note: this model depends on a background reader. Multiple iterations ensures that the
|
||||
// reader has time to at least start and process the test fixture before the runModel
|
||||
// test harness completes (which is a fake event loop anyway).
|
||||
iterations: 1,
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
reader := strings.NewReader("contents\nof\nstuff!")
|
||||
|
||||
src := monitor.GenericTask{
|
||||
Title: monitor.Title{
|
||||
Default: "Create a thing",
|
||||
WhileRunning: "Creating a thing",
|
||||
OnSuccess: "Created a thing",
|
||||
},
|
||||
Context: "running a thing",
|
||||
}
|
||||
|
||||
mon := progress.NewManual(-1)
|
||||
mon.Set(50)
|
||||
|
||||
value := &monitor.ShellProgress{
|
||||
Reader: reader,
|
||||
Progressable: mon,
|
||||
}
|
||||
|
||||
return partybus.Event{
|
||||
Type: syftEvent.AttestationStarted,
|
||||
Source: src,
|
||||
Value: value,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "attesting complete",
|
||||
// note: this model depends on a background reader. Multiple iterations ensures that the
|
||||
// reader has time to at least start and process the test fixture before the runModel
|
||||
// test harness completes (which is a fake event loop anyway).
|
||||
iterations: 1,
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
reader := strings.NewReader("contents\nof\nstuff!")
|
||||
|
||||
src := monitor.GenericTask{
|
||||
Title: monitor.Title{
|
||||
Default: "Create a thing",
|
||||
WhileRunning: "Creating a thing",
|
||||
OnSuccess: "Created a thing",
|
||||
},
|
||||
Context: "running a thing",
|
||||
}
|
||||
|
||||
mon := progress.NewManual(-1)
|
||||
mon.Set(50)
|
||||
mon.SetCompleted()
|
||||
|
||||
value := &monitor.ShellProgress{
|
||||
Reader: reader,
|
||||
Progressable: mon,
|
||||
}
|
||||
|
||||
return partybus.Event{
|
||||
Type: syftEvent.AttestationStarted,
|
||||
Source: src,
|
||||
Value: value,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
event := tt.eventFn(t)
|
||||
handler := New(DefaultHandlerConfig())
|
||||
handler.WindowSize = tea.WindowSizeMsg{
|
||||
Width: 100,
|
||||
Height: 80,
|
||||
}
|
||||
|
||||
models, _ := handler.Handle(event)
|
||||
require.Len(t, models, 2)
|
||||
|
||||
t.Run("task line", func(t *testing.T) {
|
||||
tsk, ok := models[0].(taskprogress.Model)
|
||||
require.True(t, ok)
|
||||
|
||||
gotModel := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
|
||||
Time: time.Now(),
|
||||
Sequence: tsk.Sequence(),
|
||||
ID: tsk.ID(),
|
||||
})
|
||||
|
||||
got := gotModel.View()
|
||||
|
||||
t.Log(got)
|
||||
snaps.MatchSnapshot(t, got)
|
||||
})
|
||||
|
||||
t.Run("log", func(t *testing.T) {
|
||||
log, ok := models[1].(attestLogFrame)
|
||||
require.True(t, ok)
|
||||
|
||||
gotModel := runModel(t, log, tt.iterations, attestLogFrameTickMsg{
|
||||
Time: time.Now(),
|
||||
Sequence: log.sequence,
|
||||
ID: log.id,
|
||||
}, log.reader.running)
|
||||
|
||||
got := gotModel.View()
|
||||
|
||||
t.Log(got)
|
||||
snaps.MatchSnapshot(t, got)
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/google/uuid"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
"github.com/wagoodman/go-progress"
|
||||
|
||||
"github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
"github.com/anchore/bubbly/bubbles/tree"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/event/monitor"
|
||||
syftEventParsers "github.com/anchore/syft/syft/event/parsers"
|
||||
)
|
||||
|
||||
// we standardize how rows are instantiated to ensure consistency in the appearance across the UI
|
||||
type taskModelFactory func(title taskprogress.Title, opts ...taskprogress.Option) taskprogress.Model
|
||||
|
||||
var _ tea.Model = (*catalogerTaskModel)(nil)
|
||||
|
||||
type catalogerTaskModel struct {
|
||||
model tree.Model
|
||||
modelFactory taskModelFactory
|
||||
}
|
||||
|
||||
func newCatalogerTaskTreeModel(f taskModelFactory) *catalogerTaskModel {
|
||||
t := tree.NewModel()
|
||||
t.Padding = " "
|
||||
t.RootsWithoutPrefix = true
|
||||
return &catalogerTaskModel{
|
||||
modelFactory: f,
|
||||
model: t,
|
||||
}
|
||||
}
|
||||
|
||||
type newCatalogerTaskRowEvent struct {
|
||||
info monitor.GenericTask
|
||||
prog progress.StagedProgressable
|
||||
}
|
||||
|
||||
func (cts catalogerTaskModel) Init() tea.Cmd {
|
||||
return cts.model.Init()
|
||||
}
|
||||
|
||||
func (cts catalogerTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
event, ok := msg.(newCatalogerTaskRowEvent)
|
||||
if !ok {
|
||||
model, cmd := cts.model.Update(msg)
|
||||
cts.model = model.(tree.Model)
|
||||
|
||||
return cts, cmd
|
||||
}
|
||||
|
||||
info, prog := event.info, event.prog
|
||||
|
||||
tsk := cts.modelFactory(
|
||||
taskprogress.Title{
|
||||
Default: info.Title.Default,
|
||||
Running: info.Title.WhileRunning,
|
||||
Success: info.Title.OnSuccess,
|
||||
},
|
||||
taskprogress.WithStagedProgressable(prog),
|
||||
)
|
||||
|
||||
if info.Context != "" {
|
||||
tsk.Context = []string{info.Context}
|
||||
}
|
||||
|
||||
tsk.HideOnSuccess = info.HideOnSuccess
|
||||
tsk.HideStageOnSuccess = info.HideStageOnSuccess
|
||||
tsk.HideProgressOnSuccess = true
|
||||
|
||||
if info.ParentID != "" {
|
||||
tsk.TitleStyle = lipgloss.NewStyle()
|
||||
}
|
||||
|
||||
if err := cts.model.Add(info.ParentID, info.ID, tsk); err != nil {
|
||||
log.WithFields("error", err).Error("unable to add cataloger task to tree model")
|
||||
}
|
||||
|
||||
return cts, tsk.Init()
|
||||
}
|
||||
|
||||
func (cts catalogerTaskModel) View() string {
|
||||
return cts.model.View()
|
||||
}
|
||||
|
||||
func (m *Handler) handleCatalogerTaskStarted(e partybus.Event) ([]tea.Model, tea.Cmd) {
|
||||
mon, info, err := syftEventParsers.ParseCatalogerTaskStarted(e)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Debug("unable to parse event")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var models []tea.Model
|
||||
|
||||
// only create the new cataloger task tree once to manage all cataloger task events
|
||||
m.onNewCatalogerTask.Do(func() {
|
||||
models = append(models, newCatalogerTaskTreeModel(m.newTaskProgress))
|
||||
})
|
||||
|
||||
// we need to update the cataloger task model with a new row. We should never update the model outside of the
|
||||
// bubbletea update-render event loop. Instead, we return a command that will be executed by the bubbletea runtime,
|
||||
// producing a message that is passed to the cataloger task model. This is the prescribed way to update models
|
||||
// in bubbletea.
|
||||
|
||||
if info.ID == "" {
|
||||
// ID is optional from the consumer perspective, but required internally
|
||||
info.ID = uuid.Must(uuid.NewRandom()).String()
|
||||
}
|
||||
|
||||
cmd := func() tea.Msg {
|
||||
// this message will cause the cataloger task model to add a new row to the output based on the given task
|
||||
// information and progress data.
|
||||
return newCatalogerTaskRowEvent{
|
||||
info: *info,
|
||||
prog: mon,
|
||||
}
|
||||
}
|
||||
|
||||
return models, cmd
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/gkampitakis/go-snaps/snaps"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
"github.com/wagoodman/go-progress"
|
||||
|
||||
syftEvent "github.com/anchore/syft/syft/event"
|
||||
"github.com/anchore/syft/syft/event/monitor"
|
||||
)
|
||||
|
||||
func TestHandler_handleCatalogerTaskStarted(t *testing.T) {
|
||||
title := monitor.Title{
|
||||
Default: "some task title",
|
||||
OnSuccess: "some task done",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
eventFn func(*testing.T) partybus.Event
|
||||
iterations int
|
||||
}{
|
||||
{
|
||||
name: "cataloging task in progress",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
value := &monitor.CatalogerTaskProgress{
|
||||
AtomicStage: progress.NewAtomicStage("some stage"),
|
||||
Manual: progress.NewManual(100),
|
||||
}
|
||||
|
||||
value.Manual.Add(50)
|
||||
|
||||
return partybus.Event{
|
||||
Type: syftEvent.CatalogerTaskStarted,
|
||||
Source: monitor.GenericTask{
|
||||
Title: title,
|
||||
HideOnSuccess: false,
|
||||
HideStageOnSuccess: false,
|
||||
ID: "my-id",
|
||||
},
|
||||
Value: value,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cataloging sub task in progress",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
value := &monitor.CatalogerTaskProgress{
|
||||
AtomicStage: progress.NewAtomicStage("some stage"),
|
||||
Manual: progress.NewManual(100),
|
||||
}
|
||||
|
||||
value.Manual.Add(50)
|
||||
|
||||
return partybus.Event{
|
||||
Type: syftEvent.CatalogerTaskStarted,
|
||||
Source: monitor.GenericTask{
|
||||
Title: title,
|
||||
HideOnSuccess: false,
|
||||
HideStageOnSuccess: false,
|
||||
ID: "my-id",
|
||||
ParentID: "top-level-task",
|
||||
},
|
||||
Value: value,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cataloging sub task complete",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
value := &monitor.CatalogerTaskProgress{
|
||||
AtomicStage: progress.NewAtomicStage("some stage"),
|
||||
Manual: progress.NewManual(100),
|
||||
}
|
||||
|
||||
value.SetCompleted()
|
||||
|
||||
return partybus.Event{
|
||||
Type: syftEvent.CatalogerTaskStarted,
|
||||
Source: monitor.GenericTask{
|
||||
Title: title,
|
||||
HideOnSuccess: false,
|
||||
HideStageOnSuccess: false,
|
||||
ID: "my-id",
|
||||
ParentID: "top-level-task",
|
||||
},
|
||||
Value: value,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cataloging sub task complete -- hide stage",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
value := &monitor.CatalogerTaskProgress{
|
||||
AtomicStage: progress.NewAtomicStage("some stage"),
|
||||
Manual: progress.NewManual(100),
|
||||
}
|
||||
|
||||
value.SetCompleted()
|
||||
|
||||
return partybus.Event{
|
||||
Type: syftEvent.CatalogerTaskStarted,
|
||||
Source: monitor.GenericTask{
|
||||
Title: title,
|
||||
HideOnSuccess: false,
|
||||
HideStageOnSuccess: true,
|
||||
ID: "my-id",
|
||||
ParentID: "top-level-task",
|
||||
},
|
||||
Value: value,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cataloging sub task complete with removal",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
value := &monitor.CatalogerTaskProgress{
|
||||
AtomicStage: progress.NewAtomicStage("some stage"),
|
||||
Manual: progress.NewManual(100),
|
||||
}
|
||||
|
||||
value.SetCompleted()
|
||||
|
||||
return partybus.Event{
|
||||
Type: syftEvent.CatalogerTaskStarted,
|
||||
Source: monitor.GenericTask{
|
||||
Title: title,
|
||||
HideOnSuccess: true,
|
||||
HideStageOnSuccess: false,
|
||||
ID: "my-id",
|
||||
ParentID: "top-level-task",
|
||||
},
|
||||
Value: value,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// need to be able to get the initial newCatalogerTaskRowEvent + initialize the nested taskprogress model
|
||||
if tt.iterations == 0 {
|
||||
tt.iterations = 2
|
||||
}
|
||||
|
||||
e := tt.eventFn(t)
|
||||
handler := New(DefaultHandlerConfig())
|
||||
handler.WindowSize = tea.WindowSizeMsg{
|
||||
Width: 100,
|
||||
Height: 80,
|
||||
}
|
||||
|
||||
info := monitor.GenericTask{
|
||||
Title: monitor.Title{
|
||||
Default: "Catalog contents",
|
||||
WhileRunning: "Cataloging contents",
|
||||
OnSuccess: "Cataloged contents",
|
||||
},
|
||||
ID: "top-level-task",
|
||||
}
|
||||
|
||||
// note: this line / event is not under test, only needed to show a sub status
|
||||
kickoffEvent := &monitor.CatalogerTaskProgress{
|
||||
AtomicStage: progress.NewAtomicStage(""),
|
||||
Manual: progress.NewManual(-1),
|
||||
}
|
||||
|
||||
models, cmd := handler.Handle(
|
||||
partybus.Event{
|
||||
Type: syftEvent.CatalogerTaskStarted,
|
||||
Source: info,
|
||||
Value: progress.StagedProgressable(kickoffEvent),
|
||||
},
|
||||
)
|
||||
require.Len(t, models, 1)
|
||||
require.NotNil(t, cmd)
|
||||
model := models[0]
|
||||
|
||||
tr, ok := model.(*catalogerTaskModel)
|
||||
require.True(t, ok)
|
||||
|
||||
gotModel := runModel(t, tr, tt.iterations, cmd())
|
||||
|
||||
models, cmd = handler.Handle(e)
|
||||
require.Len(t, models, 0)
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
gotModel = runModel(t, gotModel, tt.iterations, cmd())
|
||||
|
||||
got := gotModel.View()
|
||||
|
||||
t.Log(got)
|
||||
snaps.MatchSnapshot(t, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
|
||||
"github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
stereoEventParsers "github.com/anchore/stereoscope/pkg/event/parsers"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
)
|
||||
|
||||
func (m *Handler) handleFetchImage(e partybus.Event) []tea.Model {
|
||||
imgName, prog, err := stereoEventParsers.ParseFetchImage(e)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Debug("unable to parse event")
|
||||
return nil
|
||||
}
|
||||
|
||||
tsk := m.newTaskProgress(
|
||||
taskprogress.Title{
|
||||
Default: "Load image",
|
||||
Running: "Loading image",
|
||||
Success: "Loaded image",
|
||||
},
|
||||
taskprogress.WithStagedProgressable(prog),
|
||||
)
|
||||
if imgName != "" {
|
||||
tsk.Context = []string{imgName}
|
||||
}
|
||||
|
||||
return []tea.Model{tsk}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/gkampitakis/go-snaps/snaps"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
"github.com/wagoodman/go-progress"
|
||||
|
||||
"github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
stereoscopeEvent "github.com/anchore/stereoscope/pkg/event"
|
||||
)
|
||||
|
||||
func TestHandler_handleFetchImage(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
eventFn func(*testing.T) partybus.Event
|
||||
iterations int
|
||||
}{
|
||||
{
|
||||
name: "fetch image in progress",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
prog := &progress.Manual{}
|
||||
prog.SetTotal(100)
|
||||
prog.Set(50)
|
||||
|
||||
mon := struct {
|
||||
progress.Progressable
|
||||
progress.Stager
|
||||
}{
|
||||
Progressable: prog,
|
||||
Stager: &progress.Stage{
|
||||
Current: "current",
|
||||
},
|
||||
}
|
||||
|
||||
return partybus.Event{
|
||||
Type: stereoscopeEvent.FetchImage,
|
||||
Source: "the-image",
|
||||
Value: mon,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch image complete",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
prog := &progress.Manual{}
|
||||
prog.SetTotal(100)
|
||||
prog.Set(100)
|
||||
prog.SetCompleted()
|
||||
|
||||
mon := struct {
|
||||
progress.Progressable
|
||||
progress.Stager
|
||||
}{
|
||||
Progressable: prog,
|
||||
Stager: &progress.Stage{
|
||||
Current: "current",
|
||||
},
|
||||
}
|
||||
|
||||
return partybus.Event{
|
||||
Type: stereoscopeEvent.FetchImage,
|
||||
Source: "the-image",
|
||||
Value: mon,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
event := tt.eventFn(t)
|
||||
handler := New(DefaultHandlerConfig())
|
||||
handler.WindowSize = tea.WindowSizeMsg{
|
||||
Width: 100,
|
||||
Height: 80,
|
||||
}
|
||||
|
||||
models, _ := handler.Handle(event)
|
||||
require.Len(t, models, 1)
|
||||
model := models[0]
|
||||
|
||||
tsk, ok := model.(taskprogress.Model)
|
||||
require.True(t, ok)
|
||||
|
||||
gotModel := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
|
||||
Time: time.Now(),
|
||||
Sequence: tsk.Sequence(),
|
||||
ID: tsk.ID(),
|
||||
})
|
||||
|
||||
got := gotModel.View()
|
||||
|
||||
t.Log(got)
|
||||
snaps.MatchSnapshot(t, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
|
||||
"github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
syftEventParsers "github.com/anchore/syft/syft/event/parsers"
|
||||
)
|
||||
|
||||
func (m *Handler) handleFileIndexingStarted(e partybus.Event) []tea.Model {
|
||||
path, prog, err := syftEventParsers.ParseFileIndexingStarted(e)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Debug("unable to parse event")
|
||||
return nil
|
||||
}
|
||||
|
||||
tsk := m.newTaskProgress(
|
||||
taskprogress.Title{
|
||||
Default: "Index files system",
|
||||
Running: "Indexing file system",
|
||||
Success: "Indexed file system",
|
||||
},
|
||||
taskprogress.WithStagedProgressable(prog),
|
||||
)
|
||||
|
||||
tsk.Context = []string{path}
|
||||
|
||||
return []tea.Model{tsk}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/gkampitakis/go-snaps/snaps"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
"github.com/wagoodman/go-progress"
|
||||
|
||||
"github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
syftEvent "github.com/anchore/syft/syft/event"
|
||||
)
|
||||
|
||||
func TestHandler_handleFileIndexingStarted(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
eventFn func(*testing.T) partybus.Event
|
||||
iterations int
|
||||
}{
|
||||
{
|
||||
name: "cataloging in progress",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
prog := &progress.Manual{}
|
||||
prog.SetTotal(100)
|
||||
prog.Set(50)
|
||||
|
||||
mon := struct {
|
||||
progress.Progressable
|
||||
progress.Stager
|
||||
}{
|
||||
Progressable: prog,
|
||||
Stager: &progress.Stage{
|
||||
Current: "current",
|
||||
},
|
||||
}
|
||||
|
||||
return partybus.Event{
|
||||
Type: syftEvent.FileIndexingStarted,
|
||||
Source: "/some/path",
|
||||
Value: mon,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cataloging complete",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
prog := &progress.Manual{}
|
||||
prog.SetTotal(100)
|
||||
prog.Set(100)
|
||||
prog.SetCompleted()
|
||||
|
||||
mon := struct {
|
||||
progress.Progressable
|
||||
progress.Stager
|
||||
}{
|
||||
Progressable: prog,
|
||||
Stager: &progress.Stage{
|
||||
Current: "current",
|
||||
},
|
||||
}
|
||||
|
||||
return partybus.Event{
|
||||
Type: syftEvent.FileIndexingStarted,
|
||||
Source: "/some/path",
|
||||
Value: mon,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
event := tt.eventFn(t)
|
||||
handler := New(DefaultHandlerConfig())
|
||||
handler.WindowSize = tea.WindowSizeMsg{
|
||||
Width: 100,
|
||||
Height: 80,
|
||||
}
|
||||
|
||||
models, _ := handler.Handle(event)
|
||||
require.Len(t, models, 1)
|
||||
model := models[0]
|
||||
|
||||
tsk, ok := model.(taskprogress.Model)
|
||||
require.True(t, ok)
|
||||
|
||||
gotModel := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
|
||||
Time: time.Now(),
|
||||
Sequence: tsk.Sequence(),
|
||||
ID: tsk.ID(),
|
||||
})
|
||||
|
||||
got := gotModel.View()
|
||||
|
||||
t.Log(got)
|
||||
snaps.MatchSnapshot(t, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
"github.com/wagoodman/go-progress"
|
||||
|
||||
"github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
stereoscopeParsers "github.com/anchore/stereoscope/pkg/event/parsers"
|
||||
"github.com/anchore/stereoscope/pkg/image/containerd"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
)
|
||||
|
||||
var _ interface {
|
||||
progress.Stager
|
||||
progress.Progressable
|
||||
} = (*containerdPullProgressAdapter)(nil)
|
||||
|
||||
type containerdPullStatus interface {
|
||||
Complete() bool
|
||||
Layers() []containerd.LayerID
|
||||
Current(containerd.LayerID) progress.Progressable
|
||||
}
|
||||
|
||||
type containerdPullProgressAdapter struct {
|
||||
status containerdPullStatus
|
||||
formatter containerdPullStatusFormatter
|
||||
}
|
||||
|
||||
type containerdPullStatusFormatter struct {
|
||||
auxInfoStyle lipgloss.Style
|
||||
pullCompletedStyle lipgloss.Style
|
||||
pullDownloadStyle lipgloss.Style
|
||||
pullStageChars []string
|
||||
layerCaps []string
|
||||
}
|
||||
|
||||
func (m *Handler) handlePullContainerdImage(e partybus.Event) []tea.Model {
|
||||
_, pullStatus, err := stereoscopeParsers.ParsePullContainerdImage(e)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Debug("unable to parse event")
|
||||
return nil
|
||||
}
|
||||
|
||||
if pullStatus == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tsk := m.newTaskProgress(
|
||||
taskprogress.Title{
|
||||
Default: "Pull image",
|
||||
Running: "Pulling image",
|
||||
Success: "Pulled image",
|
||||
},
|
||||
taskprogress.WithStagedProgressable(
|
||||
newContainerdPullProgressAdapter(pullStatus),
|
||||
),
|
||||
)
|
||||
|
||||
tsk.HintStyle = lipgloss.NewStyle()
|
||||
tsk.HintEndCaps = nil
|
||||
|
||||
return []tea.Model{tsk}
|
||||
}
|
||||
|
||||
func newContainerdPullProgressAdapter(status *containerd.PullStatus) *containerdPullProgressAdapter {
|
||||
return &containerdPullProgressAdapter{
|
||||
status: status,
|
||||
formatter: newContainerdPullStatusFormatter(),
|
||||
}
|
||||
}
|
||||
|
||||
func newContainerdPullStatusFormatter() containerdPullStatusFormatter {
|
||||
return containerdPullStatusFormatter{
|
||||
auxInfoStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")),
|
||||
pullCompletedStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#fcba03")),
|
||||
pullDownloadStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")),
|
||||
pullStageChars: strings.Split("▁▃▄▅▆▇█", ""),
|
||||
layerCaps: strings.Split("▕▏", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func (d containerdPullProgressAdapter) Size() int64 {
|
||||
return -1
|
||||
}
|
||||
|
||||
func (d containerdPullProgressAdapter) Current() int64 {
|
||||
return 1
|
||||
}
|
||||
|
||||
func (d containerdPullProgressAdapter) Error() error {
|
||||
if d.status.Complete() {
|
||||
return progress.ErrCompleted
|
||||
}
|
||||
// TODO: return intermediate error indications
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d containerdPullProgressAdapter) Stage() string {
|
||||
return d.formatter.Render(d.status)
|
||||
}
|
||||
|
||||
// Render crafts the given docker image pull status summarized into a single line.
|
||||
func (f containerdPullStatusFormatter) Render(pullStatus containerdPullStatus) string {
|
||||
var size, current uint64
|
||||
|
||||
layers := pullStatus.Layers()
|
||||
status := make(map[containerd.LayerID]progress.Progressable)
|
||||
completed := make([]string, len(layers))
|
||||
|
||||
// fetch the current state
|
||||
for idx, layer := range layers {
|
||||
completed[idx] = " "
|
||||
status[layer] = pullStatus.Current(layer)
|
||||
}
|
||||
|
||||
numCompleted := 0
|
||||
for idx, layer := range layers {
|
||||
prog := status[layer]
|
||||
curN := prog.Current()
|
||||
curSize := prog.Size()
|
||||
|
||||
if progress.IsCompleted(prog) {
|
||||
input := f.pullStageChars[len(f.pullStageChars)-1]
|
||||
completed[idx] = f.formatPullPhase(prog.Error() != nil, input)
|
||||
} else if curN != 0 {
|
||||
var ratio float64
|
||||
switch {
|
||||
case curN == 0 || curSize < 0:
|
||||
ratio = 0
|
||||
case curN >= curSize:
|
||||
ratio = 1
|
||||
default:
|
||||
ratio = float64(curN) / float64(curSize)
|
||||
}
|
||||
|
||||
i := int(ratio * float64(len(f.pullStageChars)-1))
|
||||
input := f.pullStageChars[i]
|
||||
completed[idx] = f.formatPullPhase(status[layer].Error() != nil, input)
|
||||
}
|
||||
|
||||
if progress.IsErrCompleted(status[layer].Error()) {
|
||||
numCompleted++
|
||||
}
|
||||
}
|
||||
|
||||
for _, layer := range layers {
|
||||
prog := status[layer]
|
||||
size += uint64(prog.Size())
|
||||
current += uint64(prog.Current())
|
||||
}
|
||||
|
||||
var progStr, auxInfo string
|
||||
if len(layers) > 0 {
|
||||
render := strings.Join(completed, "")
|
||||
prefix := f.pullCompletedStyle.Render(fmt.Sprintf("%d Layers", len(layers)))
|
||||
auxInfo = f.auxInfoStyle.Render(fmt.Sprintf("[%s / %s]", humanize.Bytes(current), humanize.Bytes(size)))
|
||||
if len(layers) == numCompleted {
|
||||
auxInfo = f.auxInfoStyle.Render(fmt.Sprintf("[%s] Extracting...", humanize.Bytes(size)))
|
||||
}
|
||||
|
||||
progStr = fmt.Sprintf("%s%s%s%s", prefix, f.layerCap(false), render, f.layerCap(true))
|
||||
}
|
||||
|
||||
return progStr + auxInfo
|
||||
}
|
||||
|
||||
// formatPullPhase returns a single character that represents the status of a layer pull.
|
||||
func (f containerdPullStatusFormatter) formatPullPhase(completed bool, inputStr string) string {
|
||||
if completed {
|
||||
return f.pullCompletedStyle.Render(f.pullStageChars[len(f.pullStageChars)-1])
|
||||
}
|
||||
return f.pullDownloadStyle.Render(inputStr)
|
||||
}
|
||||
|
||||
func (f containerdPullStatusFormatter) layerCap(end bool) string {
|
||||
l := len(f.layerCaps)
|
||||
if l == 0 {
|
||||
return ""
|
||||
}
|
||||
if end {
|
||||
return f.layerCaps[l-1]
|
||||
}
|
||||
return f.layerCaps[0]
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
"github.com/wagoodman/go-progress"
|
||||
|
||||
"github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
stereoscopeParsers "github.com/anchore/stereoscope/pkg/event/parsers"
|
||||
"github.com/anchore/stereoscope/pkg/image/docker"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
)
|
||||
|
||||
var _ interface {
|
||||
progress.Stager
|
||||
progress.Progressable
|
||||
} = (*dockerPullProgressAdapter)(nil)
|
||||
|
||||
type dockerPullStatus interface {
|
||||
Complete() bool
|
||||
Layers() []docker.LayerID
|
||||
Current(docker.LayerID) docker.LayerState
|
||||
}
|
||||
|
||||
type dockerPullProgressAdapter struct {
|
||||
status dockerPullStatus
|
||||
formatter dockerPullStatusFormatter
|
||||
}
|
||||
|
||||
type dockerPullStatusFormatter struct {
|
||||
auxInfoStyle lipgloss.Style
|
||||
dockerPullCompletedStyle lipgloss.Style
|
||||
dockerPullDownloadStyle lipgloss.Style
|
||||
dockerPullExtractStyle lipgloss.Style
|
||||
dockerPullStageChars []string
|
||||
layerCaps []string
|
||||
}
|
||||
|
||||
func (m *Handler) handlePullDockerImage(e partybus.Event) []tea.Model {
|
||||
_, pullStatus, err := stereoscopeParsers.ParsePullDockerImage(e)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Debug("unable to parse event")
|
||||
return nil
|
||||
}
|
||||
|
||||
tsk := m.newTaskProgress(
|
||||
taskprogress.Title{
|
||||
Default: "Pull image",
|
||||
Running: "Pulling image",
|
||||
Success: "Pulled image",
|
||||
},
|
||||
taskprogress.WithStagedProgressable(
|
||||
newDockerPullProgressAdapter(pullStatus),
|
||||
),
|
||||
)
|
||||
|
||||
tsk.HintStyle = lipgloss.NewStyle()
|
||||
tsk.HintEndCaps = nil
|
||||
|
||||
return []tea.Model{tsk}
|
||||
}
|
||||
|
||||
func newDockerPullProgressAdapter(status dockerPullStatus) *dockerPullProgressAdapter {
|
||||
return &dockerPullProgressAdapter{
|
||||
status: status,
|
||||
formatter: newDockerPullStatusFormatter(),
|
||||
}
|
||||
}
|
||||
|
||||
func newDockerPullStatusFormatter() dockerPullStatusFormatter {
|
||||
return dockerPullStatusFormatter{
|
||||
auxInfoStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")),
|
||||
dockerPullCompletedStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#fcba03")),
|
||||
dockerPullDownloadStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")),
|
||||
dockerPullExtractStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#ffffff")),
|
||||
dockerPullStageChars: strings.Split("▁▃▄▅▆▇█", ""),
|
||||
layerCaps: strings.Split("▕▏", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func (d dockerPullProgressAdapter) Size() int64 {
|
||||
return -1
|
||||
}
|
||||
|
||||
func (d dockerPullProgressAdapter) Current() int64 {
|
||||
return 1
|
||||
}
|
||||
|
||||
func (d dockerPullProgressAdapter) Error() error {
|
||||
if d.status.Complete() {
|
||||
return progress.ErrCompleted
|
||||
}
|
||||
// TODO: return intermediate error indications
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d dockerPullProgressAdapter) Stage() string {
|
||||
return d.formatter.Render(d.status)
|
||||
}
|
||||
|
||||
// Render crafts the given docker image pull status summarized into a single line.
|
||||
func (f dockerPullStatusFormatter) Render(pullStatus dockerPullStatus) string {
|
||||
var size, current uint64
|
||||
|
||||
layers := pullStatus.Layers()
|
||||
status := make(map[docker.LayerID]docker.LayerState)
|
||||
completed := make([]string, len(layers))
|
||||
|
||||
// fetch the current state
|
||||
for idx, layer := range layers {
|
||||
completed[idx] = " "
|
||||
status[layer] = pullStatus.Current(layer)
|
||||
}
|
||||
|
||||
numCompleted := 0
|
||||
for idx, layer := range layers {
|
||||
prog := status[layer].PhaseProgress
|
||||
curN := prog.Current()
|
||||
curSize := prog.Size()
|
||||
|
||||
if progress.IsCompleted(prog) {
|
||||
input := f.dockerPullStageChars[len(f.dockerPullStageChars)-1]
|
||||
completed[idx] = f.formatDockerPullPhase(status[layer].Phase, input)
|
||||
} else if curN != 0 {
|
||||
var ratio float64
|
||||
switch {
|
||||
case curN == 0 || curSize < 0:
|
||||
ratio = 0
|
||||
case curN >= curSize:
|
||||
ratio = 1
|
||||
default:
|
||||
ratio = float64(curN) / float64(curSize)
|
||||
}
|
||||
|
||||
i := int(ratio * float64(len(f.dockerPullStageChars)-1))
|
||||
input := f.dockerPullStageChars[i]
|
||||
completed[idx] = f.formatDockerPullPhase(status[layer].Phase, input)
|
||||
}
|
||||
|
||||
if progress.IsErrCompleted(status[layer].DownloadProgress.Error()) {
|
||||
numCompleted++
|
||||
}
|
||||
}
|
||||
|
||||
for _, layer := range layers {
|
||||
prog := status[layer].DownloadProgress
|
||||
size += uint64(prog.Size())
|
||||
current += uint64(prog.Current())
|
||||
}
|
||||
|
||||
var progStr, auxInfo string
|
||||
if len(layers) > 0 {
|
||||
render := strings.Join(completed, "")
|
||||
prefix := f.dockerPullCompletedStyle.Render(fmt.Sprintf("%d Layers", len(layers)))
|
||||
auxInfo = f.auxInfoStyle.Render(fmt.Sprintf("[%s / %s]", humanize.Bytes(current), humanize.Bytes(size)))
|
||||
if len(layers) == numCompleted {
|
||||
auxInfo = f.auxInfoStyle.Render(fmt.Sprintf("[%s] Extracting...", humanize.Bytes(size)))
|
||||
}
|
||||
|
||||
progStr = fmt.Sprintf("%s%s%s%s", prefix, f.layerCap(false), render, f.layerCap(true))
|
||||
}
|
||||
|
||||
return progStr + auxInfo
|
||||
}
|
||||
|
||||
// formatDockerPullPhase returns a single character that represents the status of a layer pull.
|
||||
func (f dockerPullStatusFormatter) formatDockerPullPhase(phase docker.PullPhase, inputStr string) string {
|
||||
switch phase {
|
||||
case docker.WaitingPhase:
|
||||
// ignore any progress related to waiting
|
||||
return " "
|
||||
case docker.PullingFsPhase, docker.DownloadingPhase:
|
||||
return f.dockerPullDownloadStyle.Render(inputStr)
|
||||
case docker.DownloadCompletePhase:
|
||||
return f.dockerPullDownloadStyle.Render(f.dockerPullStageChars[len(f.dockerPullStageChars)-1])
|
||||
case docker.ExtractingPhase:
|
||||
return f.dockerPullExtractStyle.Render(inputStr)
|
||||
case docker.VerifyingChecksumPhase, docker.PullCompletePhase:
|
||||
return f.dockerPullCompletedStyle.Render(inputStr)
|
||||
case docker.AlreadyExistsPhase:
|
||||
return f.dockerPullCompletedStyle.Render(f.dockerPullStageChars[len(f.dockerPullStageChars)-1])
|
||||
default:
|
||||
return inputStr
|
||||
}
|
||||
}
|
||||
|
||||
func (f dockerPullStatusFormatter) layerCap(end bool) string {
|
||||
l := len(f.layerCaps)
|
||||
if l == 0 {
|
||||
return ""
|
||||
}
|
||||
if end {
|
||||
return f.layerCaps[l-1]
|
||||
}
|
||||
return f.layerCaps[0]
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gkampitakis/go-snaps/snaps"
|
||||
"github.com/wagoodman/go-progress"
|
||||
|
||||
"github.com/anchore/stereoscope/pkg/image/docker"
|
||||
)
|
||||
|
||||
var _ dockerPullStatus = (*mockDockerPullStatus)(nil)
|
||||
|
||||
type mockDockerPullStatus struct {
|
||||
complete bool
|
||||
layers []docker.LayerID
|
||||
current map[docker.LayerID]docker.LayerState
|
||||
}
|
||||
|
||||
func (m mockDockerPullStatus) Complete() bool {
|
||||
return m.complete
|
||||
}
|
||||
|
||||
func (m mockDockerPullStatus) Layers() []docker.LayerID {
|
||||
return m.layers
|
||||
}
|
||||
|
||||
func (m mockDockerPullStatus) Current(id docker.LayerID) docker.LayerState {
|
||||
return m.current[id]
|
||||
}
|
||||
|
||||
func Test_dockerPullStatusFormatter_Render(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status dockerPullStatus
|
||||
}{
|
||||
{
|
||||
name: "pulling",
|
||||
status: func() dockerPullStatus {
|
||||
complete := progress.NewManual(10)
|
||||
complete.Set(10)
|
||||
complete.SetCompleted()
|
||||
|
||||
quarter := progress.NewManual(10)
|
||||
quarter.Set(2)
|
||||
|
||||
half := progress.NewManual(10)
|
||||
half.Set(6)
|
||||
|
||||
empty := progress.NewManual(10)
|
||||
|
||||
return mockDockerPullStatus{
|
||||
complete: false,
|
||||
layers: []docker.LayerID{
|
||||
"sha256:1",
|
||||
"sha256:2",
|
||||
"sha256:3",
|
||||
},
|
||||
current: map[docker.LayerID]docker.LayerState{
|
||||
"sha256:1": {
|
||||
Phase: docker.ExtractingPhase,
|
||||
PhaseProgress: half,
|
||||
DownloadProgress: complete,
|
||||
},
|
||||
"sha256:2": {
|
||||
Phase: docker.DownloadingPhase,
|
||||
PhaseProgress: quarter,
|
||||
DownloadProgress: quarter,
|
||||
},
|
||||
"sha256:3": {
|
||||
Phase: docker.WaitingPhase,
|
||||
PhaseProgress: empty,
|
||||
DownloadProgress: empty,
|
||||
},
|
||||
},
|
||||
}
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "download complete",
|
||||
status: func() dockerPullStatus {
|
||||
complete := progress.NewManual(10)
|
||||
complete.Set(10)
|
||||
complete.SetCompleted()
|
||||
|
||||
quarter := progress.NewManual(10)
|
||||
quarter.Set(2)
|
||||
|
||||
half := progress.NewManual(10)
|
||||
half.Set(6)
|
||||
|
||||
empty := progress.NewManual(10)
|
||||
|
||||
return mockDockerPullStatus{
|
||||
complete: false,
|
||||
layers: []docker.LayerID{
|
||||
"sha256:1",
|
||||
"sha256:2",
|
||||
"sha256:3",
|
||||
},
|
||||
current: map[docker.LayerID]docker.LayerState{
|
||||
"sha256:1": {
|
||||
Phase: docker.ExtractingPhase,
|
||||
PhaseProgress: complete,
|
||||
DownloadProgress: complete,
|
||||
},
|
||||
"sha256:2": {
|
||||
Phase: docker.ExtractingPhase,
|
||||
PhaseProgress: quarter,
|
||||
DownloadProgress: complete,
|
||||
},
|
||||
"sha256:3": {
|
||||
Phase: docker.ExtractingPhase,
|
||||
PhaseProgress: empty,
|
||||
DownloadProgress: complete,
|
||||
},
|
||||
},
|
||||
}
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "complete",
|
||||
status: func() dockerPullStatus {
|
||||
complete := progress.NewManual(10)
|
||||
complete.Set(10)
|
||||
complete.SetCompleted()
|
||||
|
||||
return mockDockerPullStatus{
|
||||
complete: true,
|
||||
layers: []docker.LayerID{
|
||||
"sha256:1",
|
||||
"sha256:2",
|
||||
"sha256:3",
|
||||
},
|
||||
current: map[docker.LayerID]docker.LayerState{
|
||||
"sha256:1": {
|
||||
Phase: docker.PullCompletePhase,
|
||||
PhaseProgress: complete,
|
||||
DownloadProgress: complete,
|
||||
},
|
||||
"sha256:2": {
|
||||
Phase: docker.PullCompletePhase,
|
||||
PhaseProgress: complete,
|
||||
DownloadProgress: complete,
|
||||
},
|
||||
"sha256:3": {
|
||||
Phase: docker.PullCompletePhase,
|
||||
PhaseProgress: complete,
|
||||
DownloadProgress: complete,
|
||||
},
|
||||
},
|
||||
}
|
||||
}(),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newDockerPullStatusFormatter()
|
||||
snaps.MatchSnapshot(t, f.Render(tt.status))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
|
||||
"github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
stereoEventParsers "github.com/anchore/stereoscope/pkg/event/parsers"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
)
|
||||
|
||||
func (m *Handler) handleReadImage(e partybus.Event) []tea.Model {
|
||||
imgMetadata, prog, err := stereoEventParsers.ParseReadImage(e)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Debug("unable to parse event")
|
||||
return nil
|
||||
}
|
||||
|
||||
tsk := m.newTaskProgress(
|
||||
taskprogress.Title{
|
||||
Default: "Parse image",
|
||||
Running: "Parsing image",
|
||||
Success: "Parsed image",
|
||||
},
|
||||
taskprogress.WithProgress(prog),
|
||||
)
|
||||
|
||||
if imgMetadata != nil {
|
||||
tsk.Context = []string{imgMetadata.ID}
|
||||
}
|
||||
|
||||
return []tea.Model{tsk}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/gkampitakis/go-snaps/snaps"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
"github.com/wagoodman/go-progress"
|
||||
|
||||
"github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
stereoscopeEvent "github.com/anchore/stereoscope/pkg/event"
|
||||
"github.com/anchore/stereoscope/pkg/image"
|
||||
)
|
||||
|
||||
func TestHandler_handleReadImage(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
eventFn func(*testing.T) partybus.Event
|
||||
iterations int
|
||||
}{
|
||||
{
|
||||
name: "read image in progress",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
prog := &progress.Manual{}
|
||||
prog.SetTotal(100)
|
||||
prog.Set(50)
|
||||
|
||||
src := image.Metadata{
|
||||
ID: "id",
|
||||
Size: 42,
|
||||
Config: v1.ConfigFile{
|
||||
Architecture: "arch",
|
||||
Author: "auth",
|
||||
Container: "cont",
|
||||
OS: "os",
|
||||
OSVersion: "os-ver",
|
||||
Variant: "vari",
|
||||
},
|
||||
MediaType: "media",
|
||||
ManifestDigest: "digest",
|
||||
Architecture: "arch",
|
||||
Variant: "var",
|
||||
OS: "os",
|
||||
}
|
||||
|
||||
return partybus.Event{
|
||||
Type: stereoscopeEvent.ReadImage,
|
||||
Source: src,
|
||||
Value: prog,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read image complete",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
prog := &progress.Manual{}
|
||||
prog.SetTotal(100)
|
||||
prog.Set(100)
|
||||
prog.SetCompleted()
|
||||
|
||||
src := image.Metadata{
|
||||
ID: "id",
|
||||
Size: 42,
|
||||
Config: v1.ConfigFile{
|
||||
Architecture: "arch",
|
||||
Author: "auth",
|
||||
Container: "cont",
|
||||
OS: "os",
|
||||
OSVersion: "os-ver",
|
||||
Variant: "vari",
|
||||
},
|
||||
MediaType: "media",
|
||||
ManifestDigest: "digest",
|
||||
Architecture: "arch",
|
||||
Variant: "var",
|
||||
OS: "os",
|
||||
}
|
||||
|
||||
return partybus.Event{
|
||||
Type: stereoscopeEvent.ReadImage,
|
||||
Source: src,
|
||||
Value: prog,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
event := tt.eventFn(t)
|
||||
handler := New(DefaultHandlerConfig())
|
||||
handler.WindowSize = tea.WindowSizeMsg{
|
||||
Width: 100,
|
||||
Height: 80,
|
||||
}
|
||||
|
||||
models, _ := handler.Handle(event)
|
||||
require.Len(t, models, 1)
|
||||
model := models[0]
|
||||
|
||||
tsk, ok := model.(taskprogress.Model)
|
||||
require.True(t, ok)
|
||||
|
||||
gotModel := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
|
||||
Time: time.Now(),
|
||||
Sequence: tsk.Sequence(),
|
||||
ID: tsk.ID(),
|
||||
})
|
||||
|
||||
got := gotModel.View()
|
||||
|
||||
t.Log(got)
|
||||
snaps.MatchSnapshot(t, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
|
||||
"github.com/anchore/bubbly"
|
||||
"github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
stereoscopeEvent "github.com/anchore/stereoscope/pkg/event"
|
||||
syftEvent "github.com/anchore/syft/syft/event"
|
||||
)
|
||||
|
||||
var _ interface {
|
||||
bubbly.EventHandler
|
||||
bubbly.MessageListener
|
||||
bubbly.HandleWaiter
|
||||
} = (*Handler)(nil)
|
||||
|
||||
type HandlerConfig struct {
|
||||
TitleWidth int
|
||||
AdjustDefaultTask func(taskprogress.Model) taskprogress.Model
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
WindowSize tea.WindowSizeMsg
|
||||
Running *sync.WaitGroup
|
||||
Config HandlerConfig
|
||||
|
||||
bubbly.EventHandler
|
||||
|
||||
onNewCatalogerTask *sync.Once
|
||||
}
|
||||
|
||||
func DefaultHandlerConfig() HandlerConfig {
|
||||
return HandlerConfig{
|
||||
TitleWidth: 30,
|
||||
}
|
||||
}
|
||||
|
||||
func New(cfg HandlerConfig) *Handler {
|
||||
d := bubbly.NewEventDispatcher()
|
||||
|
||||
h := &Handler{
|
||||
EventHandler: d,
|
||||
Running: &sync.WaitGroup{},
|
||||
Config: cfg,
|
||||
onNewCatalogerTask: &sync.Once{},
|
||||
}
|
||||
|
||||
// register all supported event types with the respective handler functions
|
||||
d.AddHandlers(map[partybus.EventType]bubbly.EventHandlerFn{
|
||||
stereoscopeEvent.PullDockerImage: simpleHandler(h.handlePullDockerImage),
|
||||
stereoscopeEvent.PullContainerdImage: simpleHandler(h.handlePullContainerdImage),
|
||||
stereoscopeEvent.ReadImage: simpleHandler(h.handleReadImage),
|
||||
stereoscopeEvent.FetchImage: simpleHandler(h.handleFetchImage),
|
||||
syftEvent.FileIndexingStarted: simpleHandler(h.handleFileIndexingStarted),
|
||||
syftEvent.AttestationStarted: simpleHandler(h.handleAttestationStarted),
|
||||
syftEvent.CatalogerTaskStarted: h.handleCatalogerTaskStarted,
|
||||
})
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func simpleHandler(fn func(partybus.Event) []tea.Model) bubbly.EventHandlerFn {
|
||||
return func(e partybus.Event) ([]tea.Model, tea.Cmd) {
|
||||
return fn(e), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Handler) OnMessage(msg tea.Msg) {
|
||||
if msg, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.WindowSize = msg
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Handler) Wait() {
|
||||
m.Running.Wait()
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package ui
|
||||
|
||||
import "github.com/anchore/bubbly/bubbles/taskprogress"
|
||||
|
||||
func (m Handler) newTaskProgress(title taskprogress.Title, opts ...taskprogress.Option) taskprogress.Model {
|
||||
tsk := taskprogress.New(m.Running, opts...)
|
||||
|
||||
tsk.HideProgressOnSuccess = true
|
||||
tsk.HideStageOnSuccess = true
|
||||
tsk.WindowSize = m.WindowSize
|
||||
tsk.TitleWidth = m.Config.TitleWidth
|
||||
tsk.TitleOptions = title
|
||||
|
||||
if m.Config.AdjustDefaultTask != nil {
|
||||
tsk = m.Config.AdjustDefaultTask(tsk)
|
||||
}
|
||||
|
||||
return tsk
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func runModel(t testing.TB, m tea.Model, iterations int, message tea.Msg, h ...*sync.WaitGroup) tea.Model {
|
||||
t.Helper()
|
||||
if iterations == 0 {
|
||||
iterations = 1
|
||||
}
|
||||
m.Init()
|
||||
var cmd tea.Cmd = func() tea.Msg {
|
||||
return message
|
||||
}
|
||||
|
||||
for _, each := range h {
|
||||
if each != nil {
|
||||
each.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; cmd != nil && i < iterations; i++ {
|
||||
msgs := flatten(cmd())
|
||||
var nextCmds []tea.Cmd
|
||||
var next tea.Cmd
|
||||
for _, msg := range msgs {
|
||||
t.Logf("Message: %+v %+v\n", reflect.TypeOf(msg), msg)
|
||||
m, next = m.Update(msg)
|
||||
nextCmds = append(nextCmds, next)
|
||||
}
|
||||
cmd = tea.Batch(nextCmds...)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func flatten(ps ...tea.Msg) (msgs []tea.Msg) {
|
||||
for _, p := range ps {
|
||||
if bm, ok := p.(tea.BatchMsg); ok {
|
||||
for _, m := range bm {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, flatten(m())...)
|
||||
}
|
||||
} else {
|
||||
msgs = []tea.Msg{p}
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/stereoscope"
|
||||
handler "github.com/anchore/syft/cmd/syft/cli/ui"
|
||||
"github.com/anchore/syft/cmd/syft/internal/ui"
|
||||
"github.com/anchore/syft/internal/bus"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/redact"
|
||||
)
|
||||
|
||||
func AppClioSetupConfig(id clio.Identification, out io.Writer) *clio.SetupConfig {
|
||||
clioCfg := clio.NewSetupConfig(id).
|
||||
WithGlobalConfigFlag(). // add persistent -c <path> for reading an application config from
|
||||
WithGlobalLoggingFlags(). // add persistent -v and -q flags tied to the logging config
|
||||
WithConfigInRootHelp(). // --help on the root command renders the full application config in the help text
|
||||
WithUIConstructor(
|
||||
// select a UI based on the logging configuration and state of stdin (if stdin is a tty)
|
||||
func(cfg clio.Config) (*clio.UICollection, error) {
|
||||
noUI := ui.None(out, cfg.Log.Quiet)
|
||||
if !cfg.Log.AllowUI(os.Stdin) || cfg.Log.Quiet {
|
||||
return clio.NewUICollection(noUI), nil
|
||||
}
|
||||
|
||||
return clio.NewUICollection(
|
||||
ui.New(out, cfg.Log.Quiet,
|
||||
handler.New(handler.DefaultHandlerConfig()),
|
||||
),
|
||||
noUI,
|
||||
), nil
|
||||
},
|
||||
).
|
||||
WithInitializers(
|
||||
func(state *clio.State) error {
|
||||
// clio is setting up and providing the bus, redact store, and logger to the application. Once loaded,
|
||||
// we can hoist them into the internal packages for global use.
|
||||
stereoscope.SetBus(state.Bus)
|
||||
bus.Set(state.Bus)
|
||||
|
||||
redact.Set(state.RedactStore)
|
||||
|
||||
log.Set(state.Logger)
|
||||
stereoscope.SetLogger(state.Logger)
|
||||
return nil
|
||||
},
|
||||
).
|
||||
WithPostRuns(func(_ *clio.State, _ error) {
|
||||
stereoscope.Cleanup()
|
||||
})
|
||||
return clioCfg
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
"github.com/wagoodman/go-progress"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/stereoscope"
|
||||
"github.com/anchore/syft/cmd/syft/internal/options"
|
||||
"github.com/anchore/syft/cmd/syft/internal/ui"
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/bus"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
"github.com/anchore/syft/syft/event/monitor"
|
||||
"github.com/anchore/syft/syft/format"
|
||||
"github.com/anchore/syft/syft/format/cyclonedxjson"
|
||||
"github.com/anchore/syft/syft/format/spdxjson"
|
||||
"github.com/anchore/syft/syft/format/spdxtagvalue"
|
||||
"github.com/anchore/syft/syft/format/syftjson"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
const (
|
||||
attestExample = ` {{.appName}} {{.command}} --output [FORMAT] alpine:latest defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry
|
||||
`
|
||||
attestSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp
|
||||
attestHelp = attestExample + attestSchemeHelp
|
||||
cosignBinName = "cosign"
|
||||
)
|
||||
|
||||
type attestOptions struct {
|
||||
options.Config `yaml:",inline" mapstructure:",squash"`
|
||||
options.Output `yaml:",inline" mapstructure:",squash"`
|
||||
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
|
||||
options.Catalog `yaml:",inline" mapstructure:",squash"`
|
||||
Attest options.Attest `yaml:"attest" mapstructure:"attest"`
|
||||
Cache options.Cache `json:"-" yaml:"cache" mapstructure:"cache"`
|
||||
}
|
||||
|
||||
func Attest(app clio.Application) *cobra.Command {
|
||||
id := app.ID()
|
||||
|
||||
opts := defaultAttestOptions()
|
||||
|
||||
// template format explicitly not allowed
|
||||
opts.Template.Enabled = false
|
||||
|
||||
return app.SetupCommand(&cobra.Command{
|
||||
Use: "attest --output [FORMAT] <IMAGE>",
|
||||
Short: "Generate an SBOM as an attestation for the given [SOURCE] container image",
|
||||
Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from a container image as the predicate of an in-toto attestation that will be uploaded to the image registry",
|
||||
Example: internal.Tprintf(attestHelp, map[string]interface{}{
|
||||
"appName": id.Name,
|
||||
"command": "attest",
|
||||
}),
|
||||
Args: validateScanArgs,
|
||||
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
restoreStdout := ui.CaptureStdoutToTraceLog()
|
||||
defer restoreStdout()
|
||||
|
||||
return runAttest(cmd.Context(), id, &opts, args[0])
|
||||
},
|
||||
}, &opts)
|
||||
}
|
||||
|
||||
func defaultAttestOptions() attestOptions {
|
||||
return attestOptions{
|
||||
Output: defaultAttestOutputOptions(),
|
||||
UpdateCheck: options.DefaultUpdateCheck(),
|
||||
Catalog: options.DefaultCatalog(),
|
||||
Cache: options.DefaultCache(),
|
||||
}
|
||||
}
|
||||
|
||||
func defaultAttestOutputOptions() options.Output {
|
||||
return options.Output{
|
||||
AllowMultipleOutputs: false,
|
||||
AllowToFile: false,
|
||||
AllowableOptions: []string{
|
||||
string(syftjson.ID),
|
||||
string(cyclonedxjson.ID),
|
||||
string(spdxjson.ID),
|
||||
string(spdxtagvalue.ID),
|
||||
},
|
||||
Outputs: []string{syftjson.ID.String()},
|
||||
OutputFile: options.OutputFile{ //nolint:staticcheck
|
||||
Enabled: false, // explicitly not allowed
|
||||
},
|
||||
Format: options.DefaultFormat(),
|
||||
}
|
||||
}
|
||||
|
||||
func runAttest(ctx context.Context, id clio.Identification, opts *attestOptions, userInput string) error {
|
||||
// TODO: what other validation here besides binary name?
|
||||
if !commandExists(cosignBinName) {
|
||||
return fmt.Errorf("'syft attest' requires cosign to be installed, however it does not appear to be on PATH")
|
||||
}
|
||||
|
||||
// this is the file that will contain the SBOM being attested
|
||||
f, err := os.CreateTemp("", "syft-attest-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
s, err := generateSBOMForAttestation(ctx, id, &opts.Catalog, userInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to build SBOM: %w", err)
|
||||
}
|
||||
|
||||
if err = writeSBOMToFormattedFile(s, f, opts); err != nil {
|
||||
return fmt.Errorf("unable to write SBOM to file: %w", err)
|
||||
}
|
||||
|
||||
if err = createAttestation(f.Name(), opts, userInput); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bus.Notify("Attestation has been created, please check your registry for the output or use the cosign command:")
|
||||
bus.Notify(fmt.Sprintf("cosign download attestation %s", userInput))
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSBOMToFormattedFile(s *sbom.SBOM, sbomFile io.Writer, opts *attestOptions) error {
|
||||
if sbomFile == nil {
|
||||
return fmt.Errorf("no output file provided")
|
||||
}
|
||||
|
||||
encs, err := opts.Encoders()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create encoders: %w", err)
|
||||
}
|
||||
|
||||
encoders := format.NewEncoderCollection(encs...)
|
||||
encoder := encoders.GetByString(opts.Outputs[0])
|
||||
if encoder == nil {
|
||||
return fmt.Errorf("unable to find encoder for %q", opts.Outputs[0])
|
||||
}
|
||||
|
||||
if err = encoder.Encode(sbomFile, *s); err != nil {
|
||||
return fmt.Errorf("unable to encode SBOM: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createAttestation(sbomFilepath string, opts *attestOptions, userInput string) error {
|
||||
execCmd, err := attestCommand(sbomFilepath, opts, userInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to craft attest command: %w", err)
|
||||
}
|
||||
|
||||
log.WithFields("cmd", strings.Join(execCmd.Args, " ")).Trace("creating attestation")
|
||||
|
||||
// bus adapter for ui to hook into stdout via an os pipe
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create os pipe: %w", err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
mon := progress.NewManual(-1)
|
||||
|
||||
bus.Publish(
|
||||
partybus.Event{
|
||||
Type: event.AttestationStarted,
|
||||
Source: monitor.GenericTask{
|
||||
Title: monitor.Title{
|
||||
Default: "Create attestation",
|
||||
WhileRunning: "Creating attestation",
|
||||
OnSuccess: "Created attestation",
|
||||
},
|
||||
Context: "cosign",
|
||||
},
|
||||
Value: &monitor.ShellProgress{
|
||||
Reader: r,
|
||||
Progressable: mon,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
execCmd.Stdout = w
|
||||
execCmd.Stderr = w
|
||||
|
||||
// attest the SBOM
|
||||
err = execCmd.Run()
|
||||
if err != nil {
|
||||
mon.SetError(err)
|
||||
return fmt.Errorf("unable to attest SBOM: %w", err)
|
||||
}
|
||||
|
||||
mon.SetCompleted()
|
||||
return nil
|
||||
}
|
||||
|
||||
func attestCommand(sbomFilepath string, opts *attestOptions, userInput string) (*exec.Cmd, error) {
|
||||
outputNames := opts.OutputNameSet()
|
||||
var outputName string
|
||||
switch outputNames.Size() {
|
||||
case 0:
|
||||
return nil, fmt.Errorf("no output format specified")
|
||||
case 1:
|
||||
outputName = outputNames.List()[0]
|
||||
default:
|
||||
return nil, fmt.Errorf("multiple output formats specified: %s", strings.Join(outputNames.List(), ", "))
|
||||
}
|
||||
|
||||
args := []string{"attest", userInput, "--predicate", sbomFilepath, "--type", predicateType(outputName), "-y"}
|
||||
if opts.Attest.Key != "" {
|
||||
args = append(args, "--key", opts.Attest.Key.String())
|
||||
}
|
||||
|
||||
execCmd := exec.Command(cosignBinName, args...)
|
||||
execCmd.Env = os.Environ()
|
||||
if opts.Attest.Key != "" {
|
||||
execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", opts.Attest.Password))
|
||||
} else {
|
||||
// no key provided, use cosign's keyless mode
|
||||
execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1")
|
||||
}
|
||||
|
||||
return execCmd, nil
|
||||
}
|
||||
|
||||
func predicateType(outputName string) string {
|
||||
// select the Cosign predicate type based on defined output type
|
||||
// As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go
|
||||
switch strings.ToLower(outputName) {
|
||||
case "cyclonedx-json":
|
||||
return "cyclonedx"
|
||||
case "spdx-tag-value", "spdx-tv":
|
||||
return "spdx"
|
||||
case "spdx-json", "json":
|
||||
return "spdxjson"
|
||||
default:
|
||||
return "custom"
|
||||
}
|
||||
}
|
||||
|
||||
func generateSBOMForAttestation(ctx context.Context, id clio.Identification, opts *options.Catalog, userInput string) (*sbom.SBOM, error) {
|
||||
if len(opts.From) > 1 || (len(opts.From) == 1 && opts.From[0] != stereoscope.RegistryTag) {
|
||||
return nil, fmt.Errorf("attest requires use of an OCI registry directly, one or more of the specified sources is unsupported: %v", opts.From)
|
||||
}
|
||||
|
||||
src, err := getSource(ctx, opts, userInput, stereoscope.RegistryTag)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if src != nil {
|
||||
if err := src.Close(); err != nil {
|
||||
log.Tracef("unable to close source: %+v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
s, err := generateSBOM(ctx, id, src, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("no SBOM produced for %q", userInput)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func commandExists(cmd string) bool {
|
||||
_, err := exec.LookPath(cmd)
|
||||
return err == nil
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/scylladb/go-set/strset"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/clio/cliotestutils"
|
||||
"github.com/anchore/syft/cmd/syft/internal"
|
||||
"github.com/anchore/syft/cmd/syft/internal/options"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
func Test_writeSBOMToFormattedFile(t *testing.T) {
|
||||
type args struct {
|
||||
s *sbom.SBOM
|
||||
opts *attestOptions
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantSbomFile string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "go case",
|
||||
args: args{
|
||||
opts: &attestOptions{
|
||||
Output: func() options.Output {
|
||||
def := defaultAttestOutputOptions()
|
||||
def.Outputs = []string{"syft-json"}
|
||||
return def
|
||||
}(),
|
||||
},
|
||||
s: &sbom.SBOM{
|
||||
Artifacts: sbom.Artifacts{},
|
||||
Relationships: nil,
|
||||
Source: source.Description{
|
||||
ID: "source-id",
|
||||
Name: "source-name",
|
||||
Version: "source-version",
|
||||
},
|
||||
Descriptor: sbom.Descriptor{
|
||||
Name: "syft-test",
|
||||
Version: "non-version",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantSbomFile: `{
|
||||
"artifacts": [],
|
||||
"artifactRelationships": [],
|
||||
"source": {
|
||||
"id": "source-id",
|
||||
"name": "source-name",
|
||||
"version": "source-version",
|
||||
"type": "",
|
||||
"metadata": null
|
||||
},
|
||||
"distro": {},
|
||||
"descriptor": {
|
||||
"name": "syft-test",
|
||||
"version": "non-version"
|
||||
},
|
||||
"schema": {}
|
||||
}`,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sbomFile := &bytes.Buffer{}
|
||||
|
||||
err := writeSBOMToFormattedFile(tt.args.s, sbomFile, tt.args.opts)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("writeSBOMToFormattedFile() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
// redact the schema block
|
||||
re := regexp.MustCompile(`(?s)"schema":\W*\{.*?},?`)
|
||||
subject := re.ReplaceAllString(sbomFile.String(), `"schema":{}`)
|
||||
|
||||
assert.JSONEq(t, tt.wantSbomFile, subject)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_attestCommand(t *testing.T) {
|
||||
cmdPrefix := cosignBinName
|
||||
lp, err := exec.LookPath(cosignBinName)
|
||||
if err == nil {
|
||||
cmdPrefix = lp
|
||||
}
|
||||
|
||||
fullCmd := func(args string) string {
|
||||
return fmt.Sprintf("%s %s", cmdPrefix, args)
|
||||
}
|
||||
|
||||
type args struct {
|
||||
sbomFilepath string
|
||||
opts attestOptions
|
||||
userInput string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantCmd string
|
||||
wantEnvVars map[string]string
|
||||
notEnvVars []string
|
||||
wantErr require.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "with key and password",
|
||||
args: args{
|
||||
userInput: "myimage",
|
||||
sbomFilepath: "/tmp/sbom-filepath.json",
|
||||
opts: func() attestOptions {
|
||||
def := defaultAttestOptions()
|
||||
def.Outputs = []string{"syft-json"}
|
||||
def.Attest.Key = "key"
|
||||
def.Attest.Password = "password"
|
||||
return def
|
||||
}(),
|
||||
},
|
||||
wantCmd: fullCmd("attest myimage --predicate /tmp/sbom-filepath.json --type custom -y --key key"),
|
||||
wantEnvVars: map[string]string{
|
||||
"COSIGN_PASSWORD": "password",
|
||||
},
|
||||
notEnvVars: []string{
|
||||
"COSIGN_EXPERIMENTAL", // only for keyless
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyless",
|
||||
args: args{
|
||||
userInput: "myimage",
|
||||
sbomFilepath: "/tmp/sbom-filepath.json",
|
||||
opts: func() attestOptions {
|
||||
def := defaultAttestOptions()
|
||||
def.Outputs = []string{"syft-json"}
|
||||
return def
|
||||
}(),
|
||||
},
|
||||
wantCmd: fullCmd("attest myimage --predicate /tmp/sbom-filepath.json --type custom -y"),
|
||||
wantEnvVars: map[string]string{
|
||||
"COSIGN_EXPERIMENTAL": "1",
|
||||
},
|
||||
notEnvVars: []string{
|
||||
"COSIGN_PASSWORD",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.wantErr == nil {
|
||||
tt.wantErr = require.NoError
|
||||
}
|
||||
|
||||
got, err := attestCommand(tt.args.sbomFilepath, &tt.args.opts, tt.args.userInput)
|
||||
tt.wantErr(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, tt.wantCmd, got.String())
|
||||
|
||||
gotEnv := strset.New(got.Env...)
|
||||
|
||||
for k, v := range tt.wantEnvVars {
|
||||
assert.True(t, gotEnv.Has(fmt.Sprintf("%s=%s", k, v)))
|
||||
}
|
||||
|
||||
for _, k := range tt.notEnvVars {
|
||||
for _, env := range got.Env {
|
||||
fields := strings.Split(env, "=")
|
||||
if fields[0] == k {
|
||||
t.Errorf("attestCommand() unexpected environment variable %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_predicateType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "cyclonedx-json",
|
||||
want: "cyclonedx",
|
||||
},
|
||||
{
|
||||
name: "spdx-tag-value",
|
||||
want: "spdx",
|
||||
},
|
||||
{
|
||||
name: "spdx-tv",
|
||||
want: "spdx",
|
||||
},
|
||||
{
|
||||
name: "spdx-json",
|
||||
want: "spdxjson",
|
||||
},
|
||||
{
|
||||
name: "json",
|
||||
want: "spdxjson",
|
||||
},
|
||||
{
|
||||
name: "syft-json",
|
||||
want: "custom",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equalf(t, tt.want, predicateType(tt.name), "predicateType(%v)", tt.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_buildSBOMForAttestation(t *testing.T) {
|
||||
// note: this test is only meant to test that the filter function is wired
|
||||
// and not the correctness of the function in depth
|
||||
type args struct {
|
||||
id clio.Identification
|
||||
opts *options.Catalog
|
||||
userInput string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *sbom.SBOM
|
||||
wantErr require.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "do not allow directory scans",
|
||||
args: args{
|
||||
opts: func() *options.Catalog {
|
||||
def := defaultAttestOptions()
|
||||
return &def.Catalog
|
||||
}(),
|
||||
userInput: "dir:/tmp/something",
|
||||
},
|
||||
wantErr: require.Error,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.wantErr == nil {
|
||||
tt.wantErr = require.NoError
|
||||
}
|
||||
_, err := generateSBOMForAttestation(context.Background(), tt.args.id, tt.args.opts, tt.args.userInput)
|
||||
tt.wantErr(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_attestCLIWiring(t *testing.T) {
|
||||
id := clio.Identification{
|
||||
Name: "syft",
|
||||
Version: "testing",
|
||||
}
|
||||
cfg := internal.AppClioSetupConfig(id, io.Discard)
|
||||
tests := []struct {
|
||||
name string
|
||||
assertionFunc func(*testing.T, *cobra.Command, []string, ...any)
|
||||
wantOpts attestOptions
|
||||
args []string
|
||||
env map[string]string
|
||||
}{
|
||||
{
|
||||
name: "key flag is accepted",
|
||||
args: []string{"some-image:some-tag", "--key", "some-cosign-key.key"},
|
||||
assertionFunc: hasAttestOpts(options.Attest{Key: "some-cosign-key.key"}),
|
||||
},
|
||||
{
|
||||
name: "key password is read from env",
|
||||
args: []string{"some-image:some-tag", "--key", "cosign.key"},
|
||||
env: map[string]string{
|
||||
"SYFT_ATTEST_PASSWORD": "some-password",
|
||||
},
|
||||
assertionFunc: hasAttestOpts(options.Attest{
|
||||
Key: "cosign.key",
|
||||
Password: "some-password",
|
||||
}),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != nil {
|
||||
for k, v := range tt.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
app := cliotestutils.NewApplication(t, cfg, tt.assertionFunc)
|
||||
cmd := Attest(app)
|
||||
cmd.SetArgs(tt.args)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func hasAttestOpts(wantOpts options.Attest) cliotestutils.AssertionFunc {
|
||||
return func(t *testing.T, _ *cobra.Command, _ []string, cfgs ...any) {
|
||||
assert.Equal(t, len(cfgs), 1)
|
||||
attestOpts, ok := cfgs[0].(*attestOptions)
|
||||
require.True(t, ok)
|
||||
if d := cmp.Diff(wantOpts, attestOpts.Attest); d != "" {
|
||||
t.Errorf("mismatched attest options (-want +got):\n%s", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
)
|
||||
|
||||
func Cataloger(app clio.Application) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cataloger",
|
||||
Short: "Show available catalogers and configuration",
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
CatalogerList(app),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/scylladb/go-set/strset"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/cmd/syft/internal/options"
|
||||
"github.com/anchore/syft/internal/bus"
|
||||
"github.com/anchore/syft/internal/task"
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
)
|
||||
|
||||
var (
|
||||
activelyAddedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // hi green
|
||||
deselectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark grey
|
||||
activelyRemovedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // high red
|
||||
defaultStyle = lipgloss.NewStyle().Underline(true)
|
||||
deselectedDefaultStyle = lipgloss.NewStyle().Inherit(deselectedStyle).Underline(true)
|
||||
)
|
||||
|
||||
type catalogerListOptions struct {
|
||||
Output string `yaml:"output" json:"output" mapstructure:"output"`
|
||||
DefaultCatalogers []string `yaml:"default-catalogers" json:"default-catalogers" mapstructure:"default-catalogers"`
|
||||
SelectCatalogers []string `yaml:"select-catalogers" json:"select-catalogers" mapstructure:"select-catalogers"`
|
||||
ShowHidden bool `yaml:"show-hidden" json:"show-hidden" mapstructure:"show-hidden"`
|
||||
}
|
||||
|
||||
func (o *catalogerListOptions) AddFlags(flags clio.FlagSet) {
|
||||
flags.StringVarP(&o.Output, "output", "o", "format to output the cataloger list (available: table, json)")
|
||||
|
||||
flags.StringArrayVarP(&o.DefaultCatalogers, "override-default-catalogers", "", "override the default catalogers with an expression")
|
||||
|
||||
flags.StringArrayVarP(&o.SelectCatalogers, "select-catalogers", "", "select catalogers with an expression")
|
||||
|
||||
flags.BoolVarP(&o.ShowHidden, "show-hidden", "s", "show catalogers that have been de-selected")
|
||||
}
|
||||
|
||||
func defaultCatalogerListOptions() *catalogerListOptions {
|
||||
return &catalogerListOptions{
|
||||
DefaultCatalogers: []string{"all"},
|
||||
}
|
||||
}
|
||||
|
||||
func CatalogerList(app clio.Application) *cobra.Command {
|
||||
opts := defaultCatalogerListOptions()
|
||||
|
||||
return app.SetupCommand(&cobra.Command{
|
||||
Use: "list [OPTIONS]",
|
||||
Short: "List available catalogers",
|
||||
PreRunE: disableUI(app, os.Stdout),
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return runCatalogerList(opts)
|
||||
},
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func runCatalogerList(opts *catalogerListOptions) error {
|
||||
pkgTaskFactories := task.DefaultPackageTaskFactories()
|
||||
fileTaskFactories := task.DefaultFileTaskFactories()
|
||||
allPkgTasks, err := pkgTaskFactories.Tasks(task.DefaultCatalogingFactoryConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create pkg cataloger tasks: %w", err)
|
||||
}
|
||||
|
||||
allFileTasks, err := fileTaskFactories.Tasks(task.DefaultCatalogingFactoryConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create file cataloger tasks: %w", err)
|
||||
}
|
||||
|
||||
report, err := catalogerListReport(opts, [][]task.Task{allPkgTasks, allFileTasks})
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to generate cataloger list report: %w", err)
|
||||
}
|
||||
|
||||
bus.Report(report)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func catalogerListReport(opts *catalogerListOptions, allTaskGroups [][]task.Task) (string, error) {
|
||||
defaultCatalogers := options.Flatten(opts.DefaultCatalogers)
|
||||
selectCatalogers := options.Flatten(opts.SelectCatalogers)
|
||||
selectedTaskGroups, selectionEvidence, err := task.SelectInGroups(
|
||||
allTaskGroups,
|
||||
cataloging.NewSelectionRequest().
|
||||
WithDefaults(defaultCatalogers...).
|
||||
WithExpression(selectCatalogers...),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to select catalogers: %w", err)
|
||||
}
|
||||
var report string
|
||||
|
||||
switch opts.Output {
|
||||
case "json":
|
||||
report, err = renderCatalogerListJSON(flattenTaskGroups(selectedTaskGroups), selectionEvidence, defaultCatalogers, selectCatalogers)
|
||||
case "table", "":
|
||||
if opts.ShowHidden {
|
||||
report = renderCatalogerListTables(allTaskGroups, selectionEvidence)
|
||||
} else {
|
||||
report = renderCatalogerListTables(selectedTaskGroups, selectionEvidence)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to render cataloger list: %w", err)
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func flattenTaskGroups(taskGroups [][]task.Task) []task.Task {
|
||||
var allTasks []task.Task
|
||||
for _, tasks := range taskGroups {
|
||||
allTasks = append(allTasks, tasks...)
|
||||
}
|
||||
return allTasks
|
||||
}
|
||||
|
||||
func renderCatalogerListJSON(tasks []task.Task, selection task.Selection, defaultSelections, selections []string) (string, error) {
|
||||
type node struct {
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
names, tagsByName := extractTaskInfo(tasks)
|
||||
|
||||
nodesByName := make(map[string]node)
|
||||
|
||||
for name := range tagsByName {
|
||||
tokensByTask, ok := selection.TokensByTask[name]
|
||||
|
||||
var tagsSelected []string
|
||||
if ok {
|
||||
tagsSelected = tokensByTask.SelectedOn.List()
|
||||
}
|
||||
|
||||
if len(tagsSelected) == 1 && tagsSelected[0] == "all" {
|
||||
tagsSelected = tagsByName[name]
|
||||
}
|
||||
|
||||
sort.Strings(tagsSelected)
|
||||
|
||||
if tagsSelected == nil {
|
||||
// ensure collections are not null
|
||||
tagsSelected = []string{}
|
||||
}
|
||||
|
||||
nodesByName[name] = node{
|
||||
Name: name,
|
||||
Tags: tagsSelected,
|
||||
}
|
||||
}
|
||||
|
||||
type document struct {
|
||||
DefaultSelection []string `json:"default"`
|
||||
Selection []string `json:"selection"`
|
||||
Catalogers []node `json:"catalogers"`
|
||||
}
|
||||
|
||||
if selections == nil {
|
||||
// ensure collections are not null
|
||||
selections = []string{}
|
||||
}
|
||||
|
||||
doc := document{
|
||||
DefaultSelection: defaultSelections,
|
||||
Selection: selections,
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
doc.Catalogers = append(doc.Catalogers, nodesByName[name])
|
||||
}
|
||||
|
||||
by, err := json.Marshal(doc)
|
||||
|
||||
return string(by), err
|
||||
}
|
||||
|
||||
func renderCatalogerListTables(taskGroups [][]task.Task, selection task.Selection) string {
|
||||
pkgCatalogerTable := renderCatalogerListTable(taskGroups[0], selection, "Package Cataloger")
|
||||
fileCatalogerTable := renderCatalogerListTable(taskGroups[1], selection, "File Cataloger")
|
||||
|
||||
report := fileCatalogerTable + "\n" + pkgCatalogerTable + "\n"
|
||||
|
||||
hasAdditions := len(selection.Request.AddNames) > 0
|
||||
hasDefaults := len(selection.Request.DefaultNamesOrTags) > 0
|
||||
hasRemovals := len(selection.Request.RemoveNamesOrTags) > 0
|
||||
hasSubSelections := len(selection.Request.SubSelectTags) > 0
|
||||
expressions := len(selection.Request.SubSelectTags) + len(selection.Request.AddNames) + len(selection.Request.RemoveNamesOrTags)
|
||||
|
||||
var header string
|
||||
|
||||
header += fmt.Sprintf("Default selections: %d\n", len(selection.Request.DefaultNamesOrTags))
|
||||
if hasDefaults {
|
||||
for _, expr := range selection.Request.DefaultNamesOrTags {
|
||||
header += fmt.Sprintf(" • '%s'\n", expr)
|
||||
}
|
||||
}
|
||||
|
||||
header += fmt.Sprintf("Selection expressions: %d\n", expressions)
|
||||
|
||||
if hasSubSelections {
|
||||
for _, n := range selection.Request.SubSelectTags {
|
||||
header += fmt.Sprintf(" • '%s' (intersect)\n", n)
|
||||
}
|
||||
}
|
||||
if hasRemovals {
|
||||
for _, n := range selection.Request.RemoveNamesOrTags {
|
||||
header += fmt.Sprintf(" • '-%s' (remove)\n", n)
|
||||
}
|
||||
}
|
||||
if hasAdditions {
|
||||
for _, n := range selection.Request.AddNames {
|
||||
header += fmt.Sprintf(" • '+%s' (add)\n", n)
|
||||
}
|
||||
}
|
||||
|
||||
return header + report
|
||||
}
|
||||
|
||||
func renderCatalogerListTable(tasks []task.Task, selection task.Selection, kindTitle string) string {
|
||||
if len(tasks) == 0 {
|
||||
return activelyRemovedStyle.Render(fmt.Sprintf("No %ss selected", strings.ToLower(kindTitle)))
|
||||
}
|
||||
|
||||
t := table.NewWriter()
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.AppendHeader(table.Row{kindTitle, "Tags"})
|
||||
|
||||
names, tagsByName := extractTaskInfo(tasks)
|
||||
|
||||
rowsByName := make(map[string]table.Row)
|
||||
|
||||
for name, tags := range tagsByName {
|
||||
rowsByName[name] = formatRow(name, tags, selection)
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
t.AppendRow(rowsByName[name])
|
||||
}
|
||||
|
||||
report := t.Render()
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
func formatRow(name string, tags []string, selection task.Selection) table.Row {
|
||||
isIncluded := selection.Result.Has(name)
|
||||
defaults := strset.New(selection.Request.DefaultNamesOrTags...)
|
||||
var selections *task.TokenSelection
|
||||
if s, exists := selection.TokensByTask[name]; exists {
|
||||
selections = &s
|
||||
}
|
||||
|
||||
var formattedTags []string
|
||||
for _, tag := range tags {
|
||||
formattedTags = append(formattedTags, formatToken(tag, selections, isIncluded, defaults))
|
||||
}
|
||||
|
||||
var tagStr string
|
||||
if isIncluded {
|
||||
tagStr = strings.Join(formattedTags, ", ")
|
||||
} else {
|
||||
tagStr = strings.Join(formattedTags, deselectedStyle.Render(", "))
|
||||
}
|
||||
|
||||
// TODO: selection should keep warnings (non-selections) in struct
|
||||
|
||||
return table.Row{
|
||||
formatToken(name, selections, isIncluded, defaults),
|
||||
tagStr,
|
||||
}
|
||||
}
|
||||
|
||||
func formatToken(token string, selection *task.TokenSelection, included bool, defaults *strset.Set) string {
|
||||
if included && selection != nil {
|
||||
// format all tokens in selection in green
|
||||
if selection.SelectedOn.Has(token) {
|
||||
if defaults.Has(token) {
|
||||
return defaultStyle.Render(token)
|
||||
}
|
||||
return activelyAddedStyle.Render(token)
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// format all tokens in selection in red, all others in grey
|
||||
if selection != nil && selection.DeselectedOn.Has(token) {
|
||||
return activelyRemovedStyle.Render(token)
|
||||
}
|
||||
if defaults.Has(token) {
|
||||
return deselectedDefaultStyle.Render(token)
|
||||
}
|
||||
return deselectedStyle.Render(token)
|
||||
}
|
||||
|
||||
func extractTaskInfo(tasks []task.Task) ([]string, map[string][]string) {
|
||||
tagsByName := make(map[string][]string)
|
||||
var names []string
|
||||
|
||||
for _, tsk := range tasks {
|
||||
var tags []string
|
||||
name := tsk.Name()
|
||||
|
||||
if s, ok := tsk.(task.Selector); ok {
|
||||
set := strset.New(s.Selectors()...)
|
||||
set.Remove(name)
|
||||
tags = set.List()
|
||||
sort.Strings(tags)
|
||||
}
|
||||
|
||||
tagsByName[name] = tags
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
sort.Strings(names)
|
||||
|
||||
return names, tagsByName
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/scylladb/go-set/strset"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/internal/sbomsync"
|
||||
"github.com/anchore/syft/internal/task"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
)
|
||||
|
||||
var _ interface {
|
||||
task.Task
|
||||
task.Selector
|
||||
} = (*dummyTask)(nil)
|
||||
|
||||
type dummyTask struct {
|
||||
name string
|
||||
selectors []string
|
||||
}
|
||||
|
||||
func (d dummyTask) HasAllSelectors(s ...string) bool {
|
||||
return strset.New(d.selectors...).Has(s...)
|
||||
}
|
||||
|
||||
func (d dummyTask) Selectors() []string {
|
||||
return d.selectors
|
||||
}
|
||||
|
||||
func (d dummyTask) Name() string {
|
||||
return d.name
|
||||
}
|
||||
|
||||
func (d dummyTask) Execute(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func testTasks() [][]task.Task {
|
||||
return [][]task.Task{
|
||||
{
|
||||
dummyTask{
|
||||
name: "task1",
|
||||
selectors: []string{"image", "a", "b", "1"},
|
||||
},
|
||||
dummyTask{
|
||||
name: "task2",
|
||||
selectors: []string{"image", "b", "c", "2"},
|
||||
},
|
||||
dummyTask{
|
||||
name: "task3",
|
||||
selectors: []string{"directory", "c", "d", "3"},
|
||||
},
|
||||
dummyTask{
|
||||
name: "task4",
|
||||
selectors: []string{"directory", "d", "e", "4"},
|
||||
},
|
||||
},
|
||||
{
|
||||
dummyTask{
|
||||
name: "file-task1",
|
||||
selectors: []string{"file", "ft", "ft-1-b"},
|
||||
},
|
||||
dummyTask{
|
||||
name: "file-task2",
|
||||
selectors: []string{"file", "ft", "ft-2-b"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Test_catalogerListReport(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options *catalogerListOptions
|
||||
want string
|
||||
wantErr require.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "no expressions, table",
|
||||
options: func() *catalogerListOptions {
|
||||
c := defaultCatalogerListOptions()
|
||||
c.Output = "table"
|
||||
return c
|
||||
}(),
|
||||
want: `
|
||||
Default selections: 1
|
||||
• 'all'
|
||||
Selection expressions: 0
|
||||
┌────────────────┬──────────────────┐
|
||||
│ FILE CATALOGER │ TAGS │
|
||||
├────────────────┼──────────────────┤
|
||||
│ file-task1 │ file, ft, ft-1-b │
|
||||
│ file-task2 │ file, ft, ft-2-b │
|
||||
└────────────────┴──────────────────┘
|
||||
┌───────────────────┬────────────────────┐
|
||||
│ PACKAGE CATALOGER │ TAGS │
|
||||
├───────────────────┼────────────────────┤
|
||||
│ task1 │ 1, a, b, image │
|
||||
│ task2 │ 2, b, c, image │
|
||||
│ task3 │ 3, c, d, directory │
|
||||
│ task4 │ 4, d, directory, e │
|
||||
└───────────────────┴────────────────────┘
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "no expressions, json",
|
||||
options: func() *catalogerListOptions {
|
||||
c := defaultCatalogerListOptions()
|
||||
c.Output = "json"
|
||||
return c
|
||||
}(),
|
||||
want: `
|
||||
{"default":["all"],"selection":[],"catalogers":[{"name":"file-task1","tags":["file","ft","ft-1-b"]},{"name":"file-task2","tags":["file","ft","ft-2-b"]},{"name":"task1","tags":["1","a","b","image"]},{"name":"task2","tags":["2","b","c","image"]},{"name":"task3","tags":["3","c","d","directory"]},{"name":"task4","tags":["4","d","directory","e"]}]}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "no expressions, default selection, table",
|
||||
options: func() *catalogerListOptions {
|
||||
c := defaultCatalogerListOptions()
|
||||
c.Output = "table"
|
||||
c.DefaultCatalogers = []string{
|
||||
"image", // note: for backwards compatibility file will automatically be added
|
||||
}
|
||||
return c
|
||||
}(),
|
||||
want: `
|
||||
Default selections: 2
|
||||
• 'image'
|
||||
• 'file'
|
||||
Selection expressions: 0
|
||||
┌────────────────┬──────────────────┐
|
||||
│ FILE CATALOGER │ TAGS │
|
||||
├────────────────┼──────────────────┤
|
||||
│ file-task1 │ file, ft, ft-1-b │
|
||||
│ file-task2 │ file, ft, ft-2-b │
|
||||
└────────────────┴──────────────────┘
|
||||
┌───────────────────┬────────────────┐
|
||||
│ PACKAGE CATALOGER │ TAGS │
|
||||
├───────────────────┼────────────────┤
|
||||
│ task1 │ 1, a, b, image │
|
||||
│ task2 │ 2, b, c, image │
|
||||
└───────────────────┴────────────────┘
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "no expressions, default selection, json",
|
||||
options: func() *catalogerListOptions {
|
||||
c := defaultCatalogerListOptions()
|
||||
c.Output = "json"
|
||||
c.DefaultCatalogers = []string{
|
||||
"image",
|
||||
}
|
||||
return c
|
||||
}(),
|
||||
want: `
|
||||
{"default":["image"],"selection":[],"catalogers":[{"name":"file-task1","tags":["file"]},{"name":"file-task2","tags":["file"]},{"name":"task1","tags":["image"]},{"name":"task2","tags":["image"]}]}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "with expressions, default selection, table",
|
||||
options: func() *catalogerListOptions {
|
||||
c := defaultCatalogerListOptions()
|
||||
c.Output = "table"
|
||||
c.DefaultCatalogers = []string{
|
||||
"image",
|
||||
}
|
||||
c.SelectCatalogers = []string{
|
||||
"-directory",
|
||||
"+task3",
|
||||
"-c",
|
||||
"b",
|
||||
"-file",
|
||||
"+file-task1",
|
||||
}
|
||||
return c
|
||||
}(),
|
||||
want: `
|
||||
Default selections: 1
|
||||
• 'image'
|
||||
Selection expressions: 6
|
||||
• 'b' (intersect)
|
||||
• '-c' (remove)
|
||||
• '-directory' (remove)
|
||||
• '-file' (remove)
|
||||
• '+file-task1' (add)
|
||||
• '+task3' (add)
|
||||
┌────────────────┬──────────────────┐
|
||||
│ FILE CATALOGER │ TAGS │
|
||||
├────────────────┼──────────────────┤
|
||||
│ file-task1 │ file, ft, ft-1-b │
|
||||
└────────────────┴──────────────────┘
|
||||
┌───────────────────┬────────────────────┐
|
||||
│ PACKAGE CATALOGER │ TAGS │
|
||||
├───────────────────┼────────────────────┤
|
||||
│ task1 │ 1, a, b, image │
|
||||
│ task3 │ 3, c, d, directory │
|
||||
└───────────────────┴────────────────────┘
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "with comma separated expressions, table",
|
||||
options: func() *catalogerListOptions {
|
||||
c := defaultCatalogerListOptions()
|
||||
c.Output = "table"
|
||||
c.DefaultCatalogers = []string{
|
||||
"image,task3,file-task1",
|
||||
}
|
||||
c.SelectCatalogers = []string{
|
||||
"-task2,c,b",
|
||||
}
|
||||
return c
|
||||
}(),
|
||||
want: `
|
||||
Default selections: 4
|
||||
• 'file-task1'
|
||||
• 'image'
|
||||
• 'task3'
|
||||
• 'file'
|
||||
Selection expressions: 3
|
||||
• 'b' (intersect)
|
||||
• 'c' (intersect)
|
||||
• '-task2' (remove)
|
||||
┌────────────────┬──────────────────┐
|
||||
│ FILE CATALOGER │ TAGS │
|
||||
├────────────────┼──────────────────┤
|
||||
│ file-task1 │ file, ft, ft-1-b │
|
||||
│ file-task2 │ file, ft, ft-2-b │
|
||||
└────────────────┴──────────────────┘
|
||||
┌───────────────────┬────────────────────┐
|
||||
│ PACKAGE CATALOGER │ TAGS │
|
||||
├───────────────────┼────────────────────┤
|
||||
│ task1 │ 1, a, b, image │
|
||||
│ task3 │ 3, c, d, directory │
|
||||
└───────────────────┴────────────────────┘
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "with expressions, default selection, json",
|
||||
options: func() *catalogerListOptions {
|
||||
c := defaultCatalogerListOptions()
|
||||
c.Output = "json"
|
||||
c.DefaultCatalogers = []string{
|
||||
"image",
|
||||
}
|
||||
c.SelectCatalogers = []string{
|
||||
"-directory",
|
||||
"+task3",
|
||||
"-c",
|
||||
"b",
|
||||
}
|
||||
return c
|
||||
}(),
|
||||
want: `
|
||||
{"default":["image"],"selection":["+task3","-c","-directory","b"],"catalogers":[{"name":"file-task1","tags":["file"]},{"name":"file-task2","tags":["file"]},{"name":"task1","tags":["b","image"]},{"name":"task3","tags":["task3"]}]}
|
||||
`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.wantErr == nil {
|
||||
tt.wantErr = require.NoError
|
||||
}
|
||||
|
||||
got, err := catalogerListReport(tt.options, testTasks())
|
||||
tt.wantErr(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(tt.want), strings.TrimSpace(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
gologgerredact "github.com/anchore/go-logger/adapter/redact"
|
||||
"github.com/anchore/syft/internal/redact"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Initialize global state needed to test clio/cobra commands directly
|
||||
// Should be kept minimal.
|
||||
|
||||
// Initialize redact store once for all tests in the commands package
|
||||
// Redact store must be wired up here because syft will panic unless
|
||||
// a redact store is wired up exactly once
|
||||
redact.Set(gologgerredact.NewStore())
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/cmd/syft/internal/options"
|
||||
"github.com/anchore/syft/cmd/syft/internal/ui"
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/format"
|
||||
)
|
||||
|
||||
const (
|
||||
convertExample = ` {{.appName}} {{.command}} img.syft.json -o spdx-json convert a syft SBOM to spdx-json, output goes to stdout
|
||||
{{.appName}} {{.command}} img.syft.json -o cyclonedx-json=img.cdx.json convert a syft SBOM to CycloneDX, output is written to the file "img.cdx.json"
|
||||
{{.appName}} {{.command}} - -o spdx-json convert an SBOM from STDIN to spdx-json
|
||||
`
|
||||
)
|
||||
|
||||
type ConvertOptions struct {
|
||||
options.Config `yaml:",inline" mapstructure:",squash"`
|
||||
options.Output `yaml:",inline" mapstructure:",squash"`
|
||||
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
|
||||
}
|
||||
|
||||
func Convert(app clio.Application) *cobra.Command {
|
||||
id := app.ID()
|
||||
|
||||
opts := &ConvertOptions{
|
||||
UpdateCheck: options.DefaultUpdateCheck(),
|
||||
Output: options.DefaultOutput(),
|
||||
}
|
||||
|
||||
return app.SetupCommand(&cobra.Command{
|
||||
Use: "convert [SOURCE-SBOM] -o [FORMAT]",
|
||||
Short: "Convert between SBOM formats",
|
||||
Long: "[Experimental] Convert SBOM files to, and from, SPDX, CycloneDX and Syft's format. For more info about data loss between formats see https://github.com/anchore/syft/wiki/format-conversion",
|
||||
Example: internal.Tprintf(convertExample, map[string]interface{}{
|
||||
"appName": id.Name,
|
||||
"command": "convert",
|
||||
}),
|
||||
Args: validateConvertArgs,
|
||||
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
restoreStdout := ui.CaptureStdoutToTraceLog()
|
||||
defer restoreStdout()
|
||||
|
||||
return RunConvert(opts, args[0])
|
||||
},
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func validateConvertArgs(cmd *cobra.Command, args []string) error {
|
||||
return validateArgs(cmd, args, "an SBOM argument is required")
|
||||
}
|
||||
|
||||
func RunConvert(opts *ConvertOptions, userInput string) error {
|
||||
log.Warn("convert is an experimental feature, run `syft convert -h` for help")
|
||||
|
||||
writer, err := opts.SBOMWriter()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var reader io.ReadSeekCloser
|
||||
|
||||
if userInput == "-" {
|
||||
// though os.Stdin is an os.File, it does not support seeking
|
||||
// you will get errors such as "seek /dev/stdin: illegal seek".
|
||||
// We need to buffer what we read.
|
||||
reader = internal.NewBufferedSeeker(os.Stdin)
|
||||
} else {
|
||||
f, err := os.Open(userInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open SBOM file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
reader = f
|
||||
}
|
||||
|
||||
s, _, _, err := format.Decode(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode SBOM: %w", err)
|
||||
}
|
||||
|
||||
if s == nil {
|
||||
return fmt.Errorf("no SBOM produced")
|
||||
}
|
||||
|
||||
if err := writer.Write(*s); err != nil {
|
||||
return fmt.Errorf("failed to write SBOM: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/cmd/syft/internal/ui"
|
||||
"github.com/anchore/syft/internal"
|
||||
)
|
||||
|
||||
func Packages(app clio.Application, scanCmd *cobra.Command) *cobra.Command {
|
||||
id := app.ID()
|
||||
|
||||
opts := defaultScanOptions()
|
||||
|
||||
cmd := app.SetupCommand(&cobra.Command{
|
||||
Use: "packages [SOURCE]",
|
||||
Short: scanCmd.Short,
|
||||
Long: scanCmd.Long,
|
||||
Args: scanCmd.Args,
|
||||
Example: internal.Tprintf(scanHelp, map[string]interface{}{
|
||||
"appName": id.Name,
|
||||
"command": "packages",
|
||||
}),
|
||||
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
restoreStdout := ui.CaptureStdoutToTraceLog()
|
||||
defer restoreStdout()
|
||||
|
||||
return runScan(cmd.Context(), id, opts, args[0])
|
||||
},
|
||||
}, opts)
|
||||
|
||||
cmd.Deprecated = "use `syft scan` instead"
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/anchore/syft/internal/task"
|
||||
)
|
||||
|
||||
func Test_filterExpressionErrors_expressionErrorsHelp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantExpErrs []task.ErrInvalidExpression
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
wantHelp string
|
||||
}{
|
||||
{
|
||||
name: "no errors",
|
||||
err: nil,
|
||||
wantExpErrs: nil,
|
||||
wantErr: assert.NoError,
|
||||
wantHelp: "",
|
||||
},
|
||||
{
|
||||
name: "single non-expression error is retained",
|
||||
err: errors.New("foo"),
|
||||
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
|
||||
return assert.Equal(t, "foo", err.Error())
|
||||
},
|
||||
wantHelp: "",
|
||||
},
|
||||
{
|
||||
name: "multiple non-expression sibling errors are retained",
|
||||
err: func() error {
|
||||
var err error
|
||||
err = multierror.Append(err, errors.New("foo"))
|
||||
err = multierror.Append(err, errors.New("bar"))
|
||||
return err
|
||||
}(),
|
||||
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
|
||||
// note: this is the default formatting from the hashicorp multierror object
|
||||
expected := `2 errors occurred:
|
||||
* foo
|
||||
* bar
|
||||
|
||||
`
|
||||
return assert.Equal(t, expected, err.Error())
|
||||
},
|
||||
wantHelp: "",
|
||||
},
|
||||
{
|
||||
name: "has multiple expression errors (with sibling errors)",
|
||||
err: func() error {
|
||||
var err error
|
||||
err = multierror.Append(err, errors.New("foo"))
|
||||
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed})
|
||||
err = multierror.Append(err, errors.New("bar"))
|
||||
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "bar", Operation: task.SubSelectOperation, Err: task.ErrNamesNotAllowed})
|
||||
err = multierror.Append(err, errors.New("last"))
|
||||
return err
|
||||
}(),
|
||||
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
|
||||
expected := `5 errors occurred:
|
||||
* foo
|
||||
* invalid expression: "foo": tags are not allowed with this operation (must use exact names)
|
||||
* bar
|
||||
* invalid expression: "bar": names are not allowed with this operation (must use tags)
|
||||
* last
|
||||
|
||||
`
|
||||
return assert.Equal(t, expected, err.Error())
|
||||
},
|
||||
wantExpErrs: []task.ErrInvalidExpression{
|
||||
{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed},
|
||||
{Expression: "bar", Operation: task.SubSelectOperation, Err: task.ErrNamesNotAllowed},
|
||||
},
|
||||
wantHelp: `Suggestions:
|
||||
|
||||
❖ Given expression "--select-catalogers foo"
|
||||
However, tags are not allowed with this operation (must use exact names).
|
||||
Adding groups of catalogers may result in surprising behavior (create inaccurate SBOMs).
|
||||
If you are certain this is what you want to do, use "--override-default-catalogers foo" instead.
|
||||
|
||||
❖ Given expression "--select-catalogers bar"
|
||||
However, names are not allowed with this operation (must use tags).
|
||||
It seems like you are intending to add a cataloger in addition to the default set.
|
||||
... Did you mean "--select-catalogers +bar" instead?
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "has multiple expression errors (with error chains and sibling errors)",
|
||||
err: func() error {
|
||||
var err error
|
||||
err = multierror.Append(err, fmt.Errorf("foo: %w", fmt.Errorf("bar: %w", errors.New("last"))))
|
||||
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed})
|
||||
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "bar", Operation: task.SubSelectOperation, Err: task.ErrNamesNotAllowed})
|
||||
err = multierror.Append(err, errors.New("bottom"))
|
||||
|
||||
return fmt.Errorf("top: %w", fmt.Errorf("middle: %w", err))
|
||||
}(),
|
||||
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
|
||||
expected := `top: middle: 4 errors occurred:
|
||||
* foo: bar: last
|
||||
* invalid expression: "foo": tags are not allowed with this operation (must use exact names)
|
||||
* invalid expression: "bar": names are not allowed with this operation (must use tags)
|
||||
* bottom
|
||||
|
||||
`
|
||||
return assert.Equal(t, expected, err.Error())
|
||||
},
|
||||
wantExpErrs: []task.ErrInvalidExpression{
|
||||
{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed},
|
||||
{Expression: "bar", Operation: task.SubSelectOperation, Err: task.ErrNamesNotAllowed},
|
||||
},
|
||||
wantHelp: `Suggestions:
|
||||
|
||||
❖ Given expression "--select-catalogers foo"
|
||||
However, tags are not allowed with this operation (must use exact names).
|
||||
Adding groups of catalogers may result in surprising behavior (create inaccurate SBOMs).
|
||||
If you are certain this is what you want to do, use "--override-default-catalogers foo" instead.
|
||||
|
||||
❖ Given expression "--select-catalogers bar"
|
||||
However, names are not allowed with this operation (must use tags).
|
||||
It seems like you are intending to add a cataloger in addition to the default set.
|
||||
... Did you mean "--select-catalogers +bar" instead?
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "has multiple expression errors (with error chains and sibling errors)",
|
||||
err: func() error {
|
||||
var err error
|
||||
err = multierror.Append(err, fmt.Errorf("foo: %w", fmt.Errorf("bar: %w", errors.New("last"))))
|
||||
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed})
|
||||
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "bar", Operation: task.SubSelectOperation, Err: task.ErrNamesNotAllowed})
|
||||
err = multierror.Append(err, errors.New("bottom"))
|
||||
|
||||
// note we wrap the top error in a chain
|
||||
return fmt.Errorf("top: %w", fmt.Errorf("middle: %w", err))
|
||||
}(),
|
||||
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
|
||||
expected := `top: middle: 4 errors occurred:
|
||||
* foo: bar: last
|
||||
* invalid expression: "foo": tags are not allowed with this operation (must use exact names)
|
||||
* invalid expression: "bar": names are not allowed with this operation (must use tags)
|
||||
* bottom
|
||||
|
||||
`
|
||||
return assert.Equal(t, expected, err.Error())
|
||||
},
|
||||
wantExpErrs: []task.ErrInvalidExpression{
|
||||
{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed},
|
||||
{Expression: "bar", Operation: task.SubSelectOperation, Err: task.ErrNamesNotAllowed},
|
||||
},
|
||||
wantHelp: `Suggestions:
|
||||
|
||||
❖ Given expression "--select-catalogers foo"
|
||||
However, tags are not allowed with this operation (must use exact names).
|
||||
Adding groups of catalogers may result in surprising behavior (create inaccurate SBOMs).
|
||||
If you are certain this is what you want to do, use "--override-default-catalogers foo" instead.
|
||||
|
||||
❖ Given expression "--select-catalogers bar"
|
||||
However, names are not allowed with this operation (must use tags).
|
||||
It seems like you are intending to add a cataloger in addition to the default set.
|
||||
... Did you mean "--select-catalogers +bar" instead?
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "preserve for any errors within ErrInvalidExpression types",
|
||||
err: func() error {
|
||||
var err error
|
||||
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed})
|
||||
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "bar", Operation: task.SubSelectOperation, Err: errors.New("explanation")}) // this is what makes this test different...
|
||||
|
||||
return err
|
||||
}(),
|
||||
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
|
||||
// note: the errors are removed and the help text shows the enriched error help
|
||||
expected := `2 errors occurred:
|
||||
* invalid expression: "foo": tags are not allowed with this operation (must use exact names)
|
||||
* invalid expression: "bar": explanation
|
||||
|
||||
`
|
||||
return assert.Equal(t, expected, err.Error())
|
||||
},
|
||||
wantExpErrs: []task.ErrInvalidExpression{
|
||||
{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed},
|
||||
{Expression: "bar", Operation: task.SubSelectOperation, Err: errors.New("explanation")},
|
||||
},
|
||||
wantHelp: `Suggestions:
|
||||
|
||||
❖ Given expression "--select-catalogers foo"
|
||||
However, tags are not allowed with this operation (must use exact names).
|
||||
Adding groups of catalogers may result in surprising behavior (create inaccurate SBOMs).
|
||||
If you are certain this is what you want to do, use "--override-default-catalogers foo" instead.
|
||||
|
||||
`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotExpErrs := filterExpressionErrors(tt.err)
|
||||
tt.wantErr(t, tt.err) // ensure the error still remains
|
||||
assert.Equal(t, tt.wantExpErrs, gotExpErrs)
|
||||
|
||||
gotHelp := expressionErrorsHelp(gotExpErrs)
|
||||
assert.Equal(t, tt.wantHelp, gotHelp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_expressionSuggestions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expErr task.ErrInvalidExpression
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no embedded error",
|
||||
expErr: task.ErrInvalidExpression{
|
||||
Expression: "example",
|
||||
},
|
||||
want: ``,
|
||||
},
|
||||
{
|
||||
name: "general error",
|
||||
expErr: task.ErrInvalidExpression{
|
||||
Err: errors.New("general error message"),
|
||||
Expression: "example",
|
||||
},
|
||||
want: ``,
|
||||
},
|
||||
{
|
||||
name: "ErrUnknownNameOrTag with add operation",
|
||||
expErr: task.ErrInvalidExpression{
|
||||
Err: task.ErrUnknownNameOrTag,
|
||||
Operation: task.AddOperation,
|
||||
Expression: "+example",
|
||||
},
|
||||
want: ``,
|
||||
},
|
||||
{
|
||||
name: "ErrUnknownNameOrTag with subselect operation",
|
||||
expErr: task.ErrInvalidExpression{
|
||||
Err: task.ErrUnknownNameOrTag,
|
||||
Operation: task.SubSelectOperation,
|
||||
Expression: "example",
|
||||
},
|
||||
want: ``,
|
||||
},
|
||||
{
|
||||
name: "ErrNamesNotAllowed with subselect operator",
|
||||
expErr: task.ErrInvalidExpression{
|
||||
Err: task.ErrNamesNotAllowed,
|
||||
Operation: task.SubSelectOperation,
|
||||
Expression: "example",
|
||||
},
|
||||
want: ` ❖ Given expression "--select-catalogers example"
|
||||
However, names are not allowed with this operation (must use tags).
|
||||
It seems like you are intending to add a cataloger in addition to the default set.
|
||||
... Did you mean "--select-catalogers +example" instead?
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "ErrTagsNotAllowed with add operation",
|
||||
expErr: task.ErrInvalidExpression{
|
||||
Err: task.ErrTagsNotAllowed,
|
||||
Operation: task.AddOperation,
|
||||
Expression: "+example",
|
||||
},
|
||||
want: ` ❖ Given expression "--select-catalogers +example"
|
||||
However, tags are not allowed with this operation (must use exact names).
|
||||
Adding groups of catalogers may result in surprising behavior (create inaccurate SBOMs).
|
||||
If you are certain this is what you want to do, use "--override-default-catalogers example" instead.
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "ErrAllNotAllowed with subselect operation",
|
||||
expErr: task.ErrInvalidExpression{
|
||||
Err: task.ErrAllNotAllowed,
|
||||
Operation: task.SubSelectOperation,
|
||||
Expression: "example",
|
||||
},
|
||||
want: ` ❖ Given expression "--select-catalogers example"
|
||||
However, you cannot use the 'all' operand in this context.
|
||||
It seems like you are intending to use all catalogers (which is not recommended).
|
||||
... Did you mean "--override-default-catalogers example" instead?
|
||||
`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, expressionSuggetions(tt.expErr))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/cmd/syft/internal/ui"
|
||||
)
|
||||
|
||||
func Root(app clio.Application, packagesCmd *cobra.Command) *cobra.Command {
|
||||
id := app.ID()
|
||||
|
||||
opts := defaultScanOptions()
|
||||
|
||||
return app.SetupRootCommand(&cobra.Command{
|
||||
Use: fmt.Sprintf("%s [SOURCE]", app.ID().Name),
|
||||
Short: packagesCmd.Short,
|
||||
Long: packagesCmd.Long,
|
||||
Args: packagesCmd.Args,
|
||||
Example: packagesCmd.Example,
|
||||
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
restoreStdout := ui.CaptureStdoutToTraceLog()
|
||||
defer restoreStdout()
|
||||
|
||||
return runScan(cmd.Context(), id, opts, args[0])
|
||||
},
|
||||
}, opts)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user