mirror of
https://github.com/anchore/syft.git
synced 2026-10-12 14:17:20 +02:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecf11f0e3d | ||
|
|
628c2e44a5 | ||
|
|
aba0de6e65 | ||
|
|
cb0278f3bc | ||
|
|
b519340b86 | ||
|
|
2356539ebe | ||
|
|
1537e73083 | ||
|
|
52adfcbd44 | ||
|
|
a3f0d659da | ||
|
|
8a0fa5d3ad | ||
|
|
253faf5652 | ||
|
|
69d2b1ba3c | ||
|
|
b08a11e46d | ||
|
|
fef951c29b | ||
|
|
a906b9a03a |
-120
@@ -1,120 +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.12.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.7.1
|
||||
method: github-release
|
||||
with:
|
||||
repo: anchore/quill
|
||||
|
||||
# used for linting
|
||||
- name: golangci-lint
|
||||
version:
|
||||
want: v2.11.4
|
||||
method: github-release
|
||||
with:
|
||||
repo: golangci/golangci-lint
|
||||
|
||||
# used for showing the changelog at release
|
||||
- name: glow
|
||||
version:
|
||||
want: v2.1.1
|
||||
method: github-release
|
||||
with:
|
||||
repo: charmbracelet/glow
|
||||
|
||||
# used for signing the checksums file at release
|
||||
- name: cosign
|
||||
version:
|
||||
want: v3.0.5
|
||||
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.14.3
|
||||
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 running all local and CI tasks
|
||||
- name: task
|
||||
version:
|
||||
want: v3.49.1
|
||||
method: github-release
|
||||
with:
|
||||
repo: go-task/task
|
||||
|
||||
# used for triggering a release
|
||||
- name: gh
|
||||
version:
|
||||
want: v2.88.1
|
||||
method: github-release
|
||||
with:
|
||||
repo: cli/cli
|
||||
|
||||
# used to upload test fixture cache
|
||||
- name: oras
|
||||
version:
|
||||
want: v1.3.1
|
||||
method: github-release
|
||||
with:
|
||||
repo: oras-project/oras
|
||||
|
||||
# used to upload test fixture cache
|
||||
- name: yq
|
||||
version:
|
||||
want: v4.52.4
|
||||
method: github-release
|
||||
with:
|
||||
repo: mikefarah/yq
|
||||
@@ -1,27 +1,13 @@
|
||||
permit:
|
||||
- BSD.*
|
||||
- CC0.*
|
||||
- MIT.*
|
||||
- Apache.*
|
||||
- MPL.*
|
||||
- ISC
|
||||
- WTFPL
|
||||
- Unlicense
|
||||
|
||||
ignore-packages:
|
||||
# https://github.com/sorairolake/lzip-go/blob/34a2615d2abf740175c6b0a835baa08364e09430/go.sum.license#L3
|
||||
# has `SPDX-License-Identifier: Apache-2.0 OR MIT`, both of which are acceptable
|
||||
- github.com/sorairolake/lzip-go
|
||||
# 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:
|
||||
@@ -29,37 +15,3 @@ ignore-packages:
|
||||
# * GNU General Public License, version 2.0 or later (GPL-2.0-or-later).
|
||||
# (we choose Apache-2.0)
|
||||
- github.com/spdx/tools-golang
|
||||
|
||||
# from: https://github.com/xi2/xz/blob/master/LICENSE
|
||||
# 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,78 +0,0 @@
|
||||
name: "Bootstrap"
|
||||
|
||||
description: "Bootstrap all tools and dependencies"
|
||||
inputs:
|
||||
go-version:
|
||||
description: "Go version to install"
|
||||
required: true
|
||||
default: "1.26.x"
|
||||
go-dependencies:
|
||||
description: "Download go dependencies"
|
||||
required: true
|
||||
default: "true"
|
||||
cache-key-prefix:
|
||||
description: "Prefix all cache keys with this value"
|
||||
required: true
|
||||
default: "53ac821810"
|
||||
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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
if: inputs.go-version != ''
|
||||
with:
|
||||
go-version: ${{ inputs.go-version }}
|
||||
check-latest: true
|
||||
|
||||
- name: Restore tool cache
|
||||
if: inputs.tools == 'true'
|
||||
id: tool-cache
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.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
|
||||
.tool/binny list
|
||||
.tool/binny check
|
||||
|
||||
- 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
|
||||
env:
|
||||
APT_PACKAGES: ${{ inputs.bootstrap-apt-packages }}
|
||||
run: |
|
||||
IFS=' ' read -ra packages <<< "$APT_PACKAGES"
|
||||
DEBIAN_FRONTEND=noninteractive sudo apt update && sudo -E apt install -y "${packages[@]}"
|
||||
|
||||
- name: Restore ORAS cache from github actions
|
||||
if: inputs.download-test-fixture-cache == 'true'
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.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,59 +0,0 @@
|
||||
# Dependabot configuration
|
||||
#
|
||||
# Grouping behavior (see inline comments for details):
|
||||
# - Minor + patch updates: grouped into a single PR per ecosystem
|
||||
# - Major version bumps: individual PR per dependency
|
||||
# - Security updates: individual PR per dependency
|
||||
#
|
||||
# Note: "patch" refers to semver version bumps (1.2.3 -> 1.2.4), not security fixes.
|
||||
# Security updates are identified separately via GitHub's Advisory Database and
|
||||
# can be any version bump (patch, minor, or major) that fixes a known CVE.
|
||||
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "friday"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
ignore:
|
||||
- dependency-name: "github.com/aquasecurity/go-pep440-version"
|
||||
- dependency-name: "github.com/aquasecurity/go-version"
|
||||
- dependency-name: "github.com/knqyf263/go-apk-version"
|
||||
- dependency-name: "github.com/knqyf263/go-deb-version"
|
||||
groups:
|
||||
go-minor-patch:
|
||||
applies-to: version-updates # security updates get individual PRs
|
||||
patterns:
|
||||
- "*"
|
||||
update-types: # major omitted, gets individual PRs
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directories:
|
||||
- "/"
|
||||
- "/.github/actions/bootstrap"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "friday"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
groups:
|
||||
actions-minor-patch:
|
||||
applies-to: version-updates # security updates get individual PRs
|
||||
patterns:
|
||||
- "*"
|
||||
update-types: # major omitted, gets individual PRs
|
||||
- "minor"
|
||||
- "patch"
|
||||
@@ -1,28 +0,0 @@
|
||||
## Description
|
||||
|
||||
<!-- Please include a summary of the changes along with any relevant motivation and context -->
|
||||
|
||||
<!-- If CLI output changed, show an example (before/after if helpful) -->
|
||||
|
||||
<!-- If this changes application or API configuration, describe new/changed/removed options -->
|
||||
|
||||
## 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
|
||||
|
||||
## Issue references
|
||||
|
||||
<!-- If this fixes an issue, include "Fixes #<issue-number>" or otherwise list the issue references -->
|
||||
@@ -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-capabilities REFRESH=false; then
|
||||
echo "Generating capability descriptions failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(git status --porcelain | wc -l)" -ne "0" ]; then
|
||||
echo " 🔴 there is drift in capability descriptions! Please run 'make generate-capabilities' and commit the changes."
|
||||
exit 1
|
||||
fi
|
||||
@@ -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 < <(git ls-files -z "$directory")
|
||||
|
||||
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 'testdata' and at least one 'image-*' directory."""
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
if 'testdata' 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 is drift in json schema! Please run 'make generate-json-schema' and commit the changes."
|
||||
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()
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
CI_HOME="/Users/runner"
|
||||
if [[ "${HOME}" != "${CI_HOME}" ]]; then
|
||||
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
|
||||
fi
|
||||
|
||||
# Install gon (see https://github.com/mitchellh/gon for details).
|
||||
brew tap mitchellh/gon
|
||||
brew install mitchellh/gon/gon
|
||||
|
||||
# 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}"
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
IS_SNAPSHOT="$1" # e.g. "true", "false"
|
||||
|
||||
if [[ "${IS_SNAPSHOT}" == "true" ]]; then
|
||||
# This is a snapshot build —— skipping signing and notarization...
|
||||
exit 0
|
||||
fi
|
||||
|
||||
GON_CONFIG="$2" # e.g. "gon.hcl"
|
||||
NEW_NAME_WITHOUT_EXTENSION="$3" # e.g. "./dist/syft-0.1.0"
|
||||
ORIGINAL_NAME_WITHOUT_EXTENSION="./dist/output" # This should match dmg and zip output_path in the gon config file, without the extension.
|
||||
|
||||
gon "${GON_CONFIG}"
|
||||
|
||||
# Rename outputs with specified desired name
|
||||
mv -v "${ORIGINAL_NAME_WITHOUT_EXTENSION}.dmg" "${NEW_NAME_WITHOUT_EXTENSION}.dmg"
|
||||
mv -v "${ORIGINAL_NAME_WITHOUT_EXTENSION}.zip" "${NEW_NAME_WITHOUT_EXTENSION}.zip"
|
||||
@@ -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)
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -ue
|
||||
|
||||
BIN="syft"
|
||||
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
|
||||
if [[ "$(curl -SsL https://api.github.com/repos/anchore/${BIN}/releases/tags/${VERSION} | jq .prerelease)" == "true" ]] ; then
|
||||
echo "skipping publishing a version file (this is a pre-release: ${VERSION})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
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
|
||||
export AWS_DEFAULT_REGION=us-west-2
|
||||
aws s3 cp "${VERSION_FILE}" s3://toolbox-data.anchore.io/${BIN}/releases/latest/VERSION
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -ue
|
||||
|
||||
DISTDIR=$1
|
||||
|
||||
export FINGERPRINT=$(gpg --verify ${DISTDIR}/*checksums.txt.sig ${DISTDIR}/*checksums.txt 2>&1 | grep 'using RSA key' | awk '{ print $NF }')
|
||||
|
||||
if [[ "${FINGERPRINT}" == "${SIGNING_FINGERPRINT}" ]]; then
|
||||
echo 'verified signature'
|
||||
else
|
||||
echo "signed with unknown fingerprint: ${FINGERPRINT}"
|
||||
echo " expected fingerprint: ${SIGNING_FINGERPRINT}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -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,19 +30,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 #v6.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
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.
|
||||
@@ -59,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@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -73,4 +59,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
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,62 +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
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: anchore/syft # IMPORTANT! An additional protection that this is checking out code from the expected repository
|
||||
ref: main # IMPORTANT! It is CRITICAL that this only ever considers the code from main and NEVER EVER from a fork.
|
||||
|
||||
- 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@773744901bac0e8cbb5a0dc842800d45e9b2b405 #v2.9.4
|
||||
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@773744901bac0e8cbb5a0dc842800d45e9b2b405 #v2.9.4
|
||||
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 }}
|
||||
+90
-145
@@ -1,61 +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
|
||||
phase:
|
||||
description: the specific workflow phase to run or all
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- "all"
|
||||
- "install-script-only"
|
||||
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
|
||||
if: ${{ github.event.inputs.phase == 'all' }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest # This OS choice is arbitrary. None of the steps in this job are specific to either Linux or macOS.
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
|
||||
- name: Validate Apple notarization credentials
|
||||
run: .tool/quill submission list
|
||||
env:
|
||||
QUILL_NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_ISSUER }}
|
||||
QUILL_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
|
||||
QUILL_NOTARY_KEY: ${{ secrets.APPLE_NOTARY_KEY }}
|
||||
|
||||
- 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: |
|
||||
[[ "$VERSION" == v* ]] || (echo "version '$VERSION' does not have a 'v' prefix" && exit 1)
|
||||
git tag "$VERSION"
|
||||
env:
|
||||
VERSION: ${{ 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 }}
|
||||
@@ -64,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 }}
|
||||
@@ -73,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 }}
|
||||
@@ -82,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 }}
|
||||
@@ -91,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 }}
|
||||
@@ -100,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 }}
|
||||
@@ -110,119 +81,93 @@ jobs:
|
||||
|
||||
- name: Quality gate
|
||||
if: steps.static-analysis.outputs.conclusion != 'success' || steps.unit.outputs.conclusion != 'success' || steps.integration.outputs.conclusion != 'success' || steps.cli-linux.outputs.conclusion != 'success' || steps.acceptance-linux.outputs.conclusion != 'success' || steps.acceptance-mac.outputs.conclusion != 'success'
|
||||
env:
|
||||
STATIC_ANALYSIS_STATUS: ${{ steps.static-analysis.conclusion }}
|
||||
UNIT_TEST_STATUS: ${{ steps.unit.outputs.conclusion }}
|
||||
INTEGRATION_TEST_STATUS: ${{ steps.integration.outputs.conclusion }}
|
||||
ACCEPTANCE_LINUX_STATUS: ${{ steps.acceptance-linux.outputs.conclusion }}
|
||||
ACCEPTANCE_MAC_STATUS: ${{ steps.acceptance-mac.outputs.conclusion }}
|
||||
CLI_LINUX_STATUS: ${{ steps.cli-linux.outputs.conclusion }}
|
||||
run: |
|
||||
echo "Static Analysis Status: $STATIC_ANALYSIS_STATUS"
|
||||
echo "Unit Test Status: $UNIT_TEST_STATUS"
|
||||
echo "Integration Test Status: $INTEGRATION_TEST_STATUS"
|
||||
echo "Acceptance Test (Linux) Status: $ACCEPTANCE_LINUX_STATUS"
|
||||
echo "Acceptance Test (Mac) Status: $ACCEPTANCE_MAC_STATUS"
|
||||
echo "CLI Test (Linux) Status: $CLI_LINUX_STATUS"
|
||||
echo "Static Analysis Status: ${{ steps.static-analysis.conclusion }}"
|
||||
echo "Unit Test Status: ${{ steps.unit.outputs.conclusion }}"
|
||||
echo "Integration Test Status: ${{ steps.integration.outputs.conclusion }}"
|
||||
echo "Acceptance Test (Linux) Status: ${{ steps.acceptance-linux.outputs.conclusion }}"
|
||||
echo "Acceptance Test (Mac) Status: ${{ steps.acceptance-mac.outputs.conclusion }}"
|
||||
echo "CLI Test (Linux) Status: ${{ steps.cli-linux.outputs.conclusion }}"
|
||||
false
|
||||
|
||||
release:
|
||||
needs: [ quality-gate ]
|
||||
if: ${{ github.event.inputs.phase == 'all' }}
|
||||
# runs-on.com: compute instances for parallel builds
|
||||
# spot disabled: reliability for build workflows (used for releases too)
|
||||
# goreleaser uses parallelism of 12, so we need more CPUs
|
||||
# s3-cache: faster actions cache
|
||||
# tmpfs: faster io-intensive workflows
|
||||
runs-on: runs-on=${{ github.run_id }}/cpu=16+32/ram=32+128/family=c5+c6+c7+c8/spot=false/extras=s3-cache+tmpfs
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
# required for goreleaser signs section with cosign
|
||||
id-token: write
|
||||
needs: [quality-gate]
|
||||
runs-on: macos-latest # Due to our code signing process, it's vital that we run our release steps on macOS.
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.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
|
||||
persist-credentials: true
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 #v4.0.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 #v4.0.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 "$VERSION" -m "Release $VERSION"
|
||||
git push origin --tags
|
||||
- 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: Import GPG key
|
||||
id: import_gpg
|
||||
uses: crazy-max/ghaction-import-gpg@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
GPG_PRIVATE_KEY: ${{ secrets.SIGNING_GPG_PRIVATE_KEY }}
|
||||
PASSPHRASE: ${{ secrets.SIGNING_GPG_PASSPHRASE }}
|
||||
|
||||
- name: GPG signing info
|
||||
run: |
|
||||
echo "fingerprint: ${{ steps.import_gpg.outputs.fingerprint }}"
|
||||
echo "keyid: ${{ steps.import_gpg.outputs.keyid }}"
|
||||
echo "name: ${{ steps.import_gpg.outputs.name }}"
|
||||
echo "email: ${{ steps.import_gpg.outputs.email }}"
|
||||
|
||||
- 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 }}
|
||||
GITHUB_TOKEN: ${{ secrets.ANCHORE_GIT_READ_TOKEN }}
|
||||
GPG_PRIVATE_KEY: ${{ secrets.SIGNING_GPG_PRIVATE_KEY }}
|
||||
PASSPHRASE: ${{ secrets.SIGNING_GPG_PASSPHRASE }}
|
||||
SIGNING_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.TOOLBOX_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.TOOLBOX_AWS_SECRET_ACCESS_KEY }}
|
||||
APPLE_DEVELOPER_ID_CERT: ${{ secrets.APPLE_DEVELOPER_ID_CERT }} # Used during macOS code signing.
|
||||
APPLE_DEVELOPER_ID_CERT_PASS: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASS }} # Used during macOS code signing.
|
||||
AC_USERNAME: ${{ secrets.ENG_CI_APPLE_ID }} # Used during macOS notarization.
|
||||
AC_PASSWORD: ${{ secrets.ENG_CI_APPLE_ID_PASS }} # Used during macOS notarization.
|
||||
|
||||
- uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 #v0.23.0
|
||||
continue-on-error: true
|
||||
- uses: anchore/sbom-action@v0
|
||||
with:
|
||||
file: go.mod
|
||||
artifact-name: sbom.spdx.json
|
||||
|
||||
- name: Notify Slack of new release
|
||||
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a #v2.1.1
|
||||
continue-on-error: true
|
||||
- uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
text: "A new Syft release has been published: https://github.com/anchore/syft/releases/tag/${{ github.event.inputs.version }}"
|
||||
blocks:
|
||||
- type: section
|
||||
text:
|
||||
type: mrkdwn
|
||||
text: |
|
||||
*A new Syft release has been published* :rocket:
|
||||
• Release: <https://github.com/anchore/syft/releases/tag/${{ github.event.inputs.version }}|${{ github.event.inputs.version }}>
|
||||
• Repo: `${{ github.repository }}`
|
||||
• Workflow: `${{ github.workflow }}`
|
||||
• Event: `${{ github.event_name }}`
|
||||
status: ${{ job.status }}
|
||||
fields: repo,workflow,action,eventName
|
||||
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-install-script:
|
||||
needs: [ release ]
|
||||
if: ${{ always() && (needs.release.result == 'success' || github.event.inputs.phase == 'install-script-only') }}
|
||||
uses: "anchore/workflows/.github/workflows/release-install-script.yaml@main"
|
||||
with:
|
||||
tag: ${{ github.event.inputs.version }}
|
||||
secrets:
|
||||
# needed for r2...
|
||||
R2_INSTALL_ACCESS_KEY_ID: ${{ secrets.OSS_R2_INSTALL_ACCESS_KEY_ID }}
|
||||
R2_INSTALL_SECRET_ACCESS_KEY: ${{ secrets.OSS_R2_INSTALL_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.TOOLBOX_CLOUDFLARE_R2_ENDPOINT }}
|
||||
# needed for s3...
|
||||
S3_INSTALL_AWS_ACCESS_KEY_ID: ${{ secrets.TOOLBOX_AWS_ACCESS_KEY_ID }}
|
||||
S3_INSTALL_AWS_SECRET_ACCESS_KEY: ${{ secrets.TOOLBOX_AWS_SECRET_ACCESS_KEY }}
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: artifacts
|
||||
path: dist/**/*
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
name: "Manage Awaiting Response Label"
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
run:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
uses: "anchore/workflows/.github/workflows/remove-awaiting-response-label.yaml@main"
|
||||
secrets:
|
||||
token: ${{ secrets.OSS_PROJECT_GH_TOKEN }}
|
||||
@@ -1,48 +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"
|
||||
# runs-on.com: general purpose instances for building test fixture cache
|
||||
# spot disabled: can run up to an hour, avoid interruptions
|
||||
# s3-cache: faster actions cache
|
||||
# family: note that m*d instances have local nvme storage which is beneficial for building large test fixture images
|
||||
runs-on: "runs-on=${{ github.run_id }}/cpu=8+16/ram=32+64/family=m5d+m5ad+m5dn+m6gd+m6id+m6idn+m7gd+m8gd/spot=false/extras=s3-cache"
|
||||
if: github.repository == 'anchore/syft' # only run for main repo
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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 "$ACTOR" --password-stdin
|
||||
env:
|
||||
ACTOR: ${{ github.actor }}
|
||||
|
||||
- name: Publish test fixture cache
|
||||
run: make upload-test-fixture-cache
|
||||
@@ -1,36 +0,0 @@
|
||||
name: "Validate GitHub Actions"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
- '.github/actions/**'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
- '.github/actions/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: "Lint"
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # for uploading SARIF results
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run zizmor"
|
||||
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
|
||||
with:
|
||||
config: .github/zizmor.yml
|
||||
# Disable SARIF upload so the step is a simple pass/fail gate
|
||||
advanced-security: false
|
||||
inputs: .github
|
||||
+254
-132
@@ -1,42 +1,47 @@
|
||||
name: "Validations"
|
||||
|
||||
# we should cancel any in-progress runs for the same workflow + PR/ref
|
||||
# so that we can avoid redundant work / save on CI minutes
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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.com: memory & general purpose instances for testing
|
||||
# spot enabled: ok to interrupt non-production workloads
|
||||
# s3-cache: faster actions cache
|
||||
# tmpfs: faster io-intensive workflows
|
||||
runs-on: &test-runner "runs-on=${{ github.run_id }}/cpu=4+8/ram=32+128/family=r5+r6+r7+r8+m4+m5+m6+m7+m8/spot=price-capacity-optimized/extras=s3-cache+tmpfs"
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
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:
|
||||
# the self-consistency tests for the output of the capabilities code generation depends on unit test
|
||||
# output from ./syft/pkg/... packages. Therefore we need to download the test fixture cache here
|
||||
# so that running the few unit tests as part of static analysis works correctly.
|
||||
download-test-fixture-cache: true
|
||||
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
|
||||
@@ -44,180 +49,297 @@ jobs:
|
||||
Unit-Test:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Unit tests"
|
||||
runs-on: *test-runner
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
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:
|
||||
download-test-fixture-cache: true
|
||||
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
|
||||
|
||||
- name: Check for capability drift
|
||||
run: make check-capability-drift
|
||||
- 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: *test-runner
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
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:
|
||||
download-test-fixture-cache: true
|
||||
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.com: compute instances for parallel builds
|
||||
# spot disabled: reliability for build workflows (used for releases too)
|
||||
# goreleaser uses parallelism of 12, so we need more CPUs
|
||||
# s3-cache: faster actions cache
|
||||
# tmpfs: faster io-intensive workflows
|
||||
runs-on: "runs-on=${{ github.run_id }}/cpu=16+32/ram=32+128/family=c5+c6+c7+c8/spot=false/extras=s3-cache+tmpfs"
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
# required for magic-cache from runs-on to function with artifact upload/download (see https://runs-on.com/caching/magic-cache/#actionsupload-artifact-compatibility)
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # v2.0.3
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
- 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:
|
||||
bootstrap-apt-packages: ""
|
||||
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
|
||||
|
||||
- name: Smoke test snapshot build
|
||||
run: make snapshot-smoke-test
|
||||
|
||||
- name: Upload snapshot artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: snapshot
|
||||
path: snapshot/
|
||||
retention-days: 30
|
||||
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: *test-runner
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
# required for magic-cache from runs-on to function with artifact upload/download (see https://runs-on.com/caching/magic-cache/#actionsupload-artifact-compatibility)
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # v2.0.3
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
with:
|
||||
download-test-fixture-cache: true
|
||||
|
||||
- name: Download snapshot artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 #v8.0.0
|
||||
with:
|
||||
name: snapshot
|
||||
name: artifacts
|
||||
path: snapshot
|
||||
|
||||
- name: Restore binary permissions
|
||||
run: chmod +x snapshot/*/syft snapshot/*/*.exe 2>/dev/null || true
|
||||
|
||||
- name: Run comparison tests (Linux)
|
||||
run: make compare-linux
|
||||
|
||||
- name: Load test image cache
|
||||
if: steps.install-test-image-cache.outputs.cache-hit == 'true'
|
||||
run: make install-test-cache-load
|
||||
|
||||
- name: Run install.sh tests (Linux)
|
||||
run: make install-test
|
||||
|
||||
- name: (cache-miss) Create test image cache
|
||||
if: steps.install-test-image-cache.outputs.cache-hit != 'true'
|
||||
run: make install-test-cache-save
|
||||
- name: Run Acceptance Tests (Linux)
|
||||
run: make acceptance-linux
|
||||
|
||||
Acceptance-Mac:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Acceptance tests (Mac)"
|
||||
needs: [Build-Snapshot-Artifacts]
|
||||
# note: macos runners aren't supported yet for runs-on managed runners.
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
with:
|
||||
bootstrap-apt-packages: ""
|
||||
go-dependencies: false
|
||||
download-test-fixture-cache: true
|
||||
|
||||
- name: Download snapshot artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 #v8.0.0
|
||||
with:
|
||||
name: snapshot
|
||||
name: artifacts
|
||||
path: snapshot
|
||||
|
||||
- name: Restore binary permissions
|
||||
run: chmod +x snapshot/*/syft 2>/dev/null || true
|
||||
- name: Restore docker image cache
|
||||
id: go-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
path: image.tar
|
||||
key: ${{ runner.os }}-${{ hashFiles('test/acceptance/mac.sh') }}
|
||||
|
||||
- name: Run comparison tests (Mac)
|
||||
run: make compare-mac
|
||||
|
||||
- name: Run install.sh tests (Mac)
|
||||
run: make install-test-ci-mac
|
||||
- name: Run Acceptance Tests (Mac)
|
||||
run: make acceptance-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: *test-runner
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
# required for magic-cache from runs-on to function with artifact upload/download (see https://runs-on.com/caching/magic-cache/#actionsupload-artifact-compatibility)
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # v2.0.3
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
with:
|
||||
download-test-fixture-cache: true
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Download snapshot artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 #v8.0.0
|
||||
- name: Restore go cache
|
||||
id: go-cache
|
||||
uses: actions/cache@v2.1.3
|
||||
with:
|
||||
name: snapshot
|
||||
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 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: Restore binary permissions
|
||||
run: chmod +x snapshot/*/syft snapshot/*/*.exe 2>/dev/null || true
|
||||
|
||||
- name: Run CLI Tests (Linux)
|
||||
run: make cli
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
rules:
|
||||
unpinned-uses:
|
||||
config:
|
||||
policies:
|
||||
# anchore/workflows is an internal repository; using @main is acceptable
|
||||
anchore/*: any
|
||||
dangerous-triggers:
|
||||
ignore:
|
||||
# Safe use of pull_request_target - only runs trusted scripts from base repo,
|
||||
# never checks out PR code, needs secrets for labeling PRs from forks
|
||||
- detect-schema-changes.yaml
|
||||
+9
-51
@@ -1,46 +1,11 @@
|
||||
# local development tailoring
|
||||
go.work
|
||||
go.work.sum
|
||||
.tool-versions
|
||||
.python-version
|
||||
|
||||
# app configuration
|
||||
/.syft.yaml
|
||||
|
||||
# tool and bin directories
|
||||
.tmp/
|
||||
bin/
|
||||
/bin
|
||||
/.bin
|
||||
/build
|
||||
CHANGELOG.md
|
||||
/test/results
|
||||
/dist
|
||||
/snapshot
|
||||
/.tool
|
||||
/.task
|
||||
/generate
|
||||
/specs
|
||||
mise.toml
|
||||
|
||||
# changelog generation
|
||||
CHANGELOG.md
|
||||
VERSION
|
||||
|
||||
# IDE configuration
|
||||
.vscode/
|
||||
.idea/
|
||||
.server/
|
||||
.vscode/
|
||||
.history/
|
||||
|
||||
# test related
|
||||
*.fingerprint
|
||||
/test/results
|
||||
coverage.txt
|
||||
*.log
|
||||
**/test-fixtures/test-observations.json
|
||||
**/testdata/test-observations.json
|
||||
|
||||
# probable archives
|
||||
.images
|
||||
*.tar
|
||||
*.jar
|
||||
*.war
|
||||
@@ -48,7 +13,11 @@ coverage.txt
|
||||
*.jpi
|
||||
*.hpi
|
||||
*.zip
|
||||
*.iml
|
||||
.idea/
|
||||
*.log
|
||||
.images
|
||||
.tmp/
|
||||
coverage.txt
|
||||
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
@@ -66,15 +35,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
-67
@@ -1,106 +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
|
||||
rules:
|
||||
# we have multiple packages in syft that might overlap with the stblib; their names reflect their purpose
|
||||
- linters:
|
||||
- revive
|
||||
text: "var-naming: avoid package names that conflict"
|
||||
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)
|
||||
|
||||
+110
-296
@@ -1,84 +1,123 @@
|
||||
version: 2
|
||||
project_name: syft
|
||||
|
||||
release:
|
||||
# If set to auto, will mark the release as not ready for production in case there is an indicator for this in the
|
||||
# tag e.g. v1.0.0-rc1 .If set to true, will mark the release as not ready for production.
|
||||
prerelease: auto
|
||||
draft: false
|
||||
|
||||
env:
|
||||
- DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
- CGO_ENABLED=0
|
||||
# If set to true, will not auto-publish the release. This is done to allow us to review the changelog before publishing.
|
||||
draft: true
|
||||
|
||||
builds:
|
||||
- id: linux-build
|
||||
dir: ./cmd/syft
|
||||
binary: syft
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm64, ppc64le, s390x]
|
||||
mod_timestamp: &build-timestamp '{{ .CommitTimestamp }}'
|
||||
ldflags: &build-ldflags |
|
||||
- binary: syft
|
||||
id: syft
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
# Set the modified timestamp on the output binary to the git timestamp (to ensure a reproducible build)
|
||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
||||
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.gitTreeState={{.Env.BUILD_GIT_TREE_STATE}}
|
||||
|
||||
- id: darwin-build
|
||||
dir: ./cmd/syft
|
||||
binary: syft
|
||||
goos: [darwin]
|
||||
goarch: [amd64, arm64]
|
||||
mod_timestamp: *build-timestamp
|
||||
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
|
||||
- binary: syft
|
||||
id: syft-win
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
# Set the modified timestamp on the output binary to the git timestamp (to ensure a reproducible build)
|
||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
||||
ldflags: |
|
||||
-w
|
||||
-s
|
||||
-extldflags '-static'
|
||||
-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.gitTreeState={{.Env.BUILD_GIT_TREE_STATE}}
|
||||
|
||||
- id: windows-build
|
||||
dir: ./cmd/syft
|
||||
binary: syft
|
||||
goos: [windows]
|
||||
goarch: [amd64, arm64]
|
||||
mod_timestamp: *build-timestamp
|
||||
ldflags: *build-ldflags
|
||||
# For more info on this macOS build, see: https://github.com/mitchellh/gon#usage-with-goreleaser
|
||||
- binary: syft
|
||||
id: syft-macos
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
# Set the modified timestamp on the output binary to the git timestamp (to ensure a reproducible build)
|
||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
||||
ldflags: |
|
||||
-w
|
||||
-s
|
||||
-extldflags '-static'
|
||||
-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.gitTreeState={{.Env.BUILD_GIT_TREE_STATE}}
|
||||
|
||||
archives:
|
||||
- id: linux-archives
|
||||
ids: [linux-build]
|
||||
- id: darwin-archives
|
||||
ids: [darwin-build]
|
||||
- id: windows-archives
|
||||
formats:
|
||||
- zip
|
||||
ids: [windows-build]
|
||||
- format: tar.gz
|
||||
builds:
|
||||
- syft # i.e. Linux only
|
||||
- format: zip # This is a hack for syft-macos! We don't actually intend to use _this_ ZIP file, we just need goreleaser to consider the ZIP file produced by gon (which will have the same file name) to be an artifact so we can use it downstream in publishing (e.g. to a homebrew tap)
|
||||
id: syft-zip
|
||||
builds:
|
||||
- syft-macos
|
||||
- syft-win
|
||||
|
||||
signs:
|
||||
- artifacts: checksum
|
||||
cmd: sh
|
||||
args:
|
||||
- '-c'
|
||||
# we should not include the zip artifact, as the artifact is mutated throughout the next macOS notarization step
|
||||
# note: sed -i is not portable
|
||||
- 'sed "/.*\.zip/d" ${artifact} > tmpfile && mv tmpfile ${artifact} && gpg --output ${signature} --detach-sign ${artifact}'
|
||||
- id: syft-macos-signing
|
||||
ids:
|
||||
- syft-macos
|
||||
cmd: ./.github/scripts/mac-sign-and-notarize.sh
|
||||
signature: "syft_${VERSION}_darwin_amd64.dmg" # This is somewhat unintuitive. This gets the DMG file recognized as an artifact. In fact, both a DMG and a ZIP file are being produced by this signing step.
|
||||
args:
|
||||
- "{{ .IsSnapshot }}"
|
||||
- "gon.hcl"
|
||||
- "./dist/syft_{{ .Version }}_darwin_amd64"
|
||||
artifacts: all
|
||||
|
||||
nfpms:
|
||||
- license: "Apache 2.0"
|
||||
maintainer: "Anchore, Inc"
|
||||
homepage: &website "https://github.com/anchore/syft"
|
||||
description: &description "A tool that generates a Software Bill Of Materials (SBOM) from container images and filesystems"
|
||||
formats: [rpm, deb]
|
||||
formats:
|
||||
- rpm
|
||||
- deb
|
||||
|
||||
brews:
|
||||
- repository:
|
||||
- tap:
|
||||
owner: anchore
|
||||
name: homebrew-syft
|
||||
token: "{{.Env.GITHUB_BREW_TOKEN}}"
|
||||
ids: [darwin-archives, linux-archives]
|
||||
homepage: *website
|
||||
description: *description
|
||||
license: "Apache License 2.0"
|
||||
|
||||
dockers:
|
||||
# production images...
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-amd64
|
||||
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:
|
||||
@@ -89,8 +128,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
|
||||
@@ -101,246 +141,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}}"
|
||||
|
||||
# nonroot images...
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-nonroot-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-amd64
|
||||
goarch: amd64
|
||||
dockerfile: Dockerfile.nonroot
|
||||
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:{{.Tag}}-nonroot-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-arm64v8
|
||||
goarch: arm64
|
||||
dockerfile: Dockerfile.nonroot
|
||||
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:{{.Tag}}-nonroot-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-ppc64le
|
||||
goarch: ppc64le
|
||||
dockerfile: Dockerfile.nonroot
|
||||
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}}-nonroot-s390x
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-s390x
|
||||
goarch: s390x
|
||||
dockerfile: Dockerfile.nonroot
|
||||
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}}"
|
||||
|
||||
# debug images...
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-debug-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-amd64
|
||||
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:{{.Tag}}-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:{{.Tag}}-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:{{.Tag}}-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}}"
|
||||
|
||||
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}}-amd64
|
||||
- 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}}-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-s390x
|
||||
|
||||
- name_template: anchore/syft:{{.Tag}}
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}-amd64
|
||||
- anchore/syft:{{.Tag}}-arm64v8
|
||||
- anchore/syft:{{.Tag}}-ppc64le
|
||||
- anchore/syft:{{.Tag}}-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:{{.Tag}}
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-s390x
|
||||
|
||||
# nonroot images...
|
||||
- name_template: anchore/syft:nonroot
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}-nonroot-amd64
|
||||
- anchore/syft:{{.Tag}}-nonroot-arm64v8
|
||||
- anchore/syft:{{.Tag}}-nonroot-ppc64le
|
||||
- anchore/syft:{{.Tag}}-nonroot-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:nonroot
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-s390x
|
||||
|
||||
- name_template: anchore/syft:{{.Tag}}-nonroot
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}-nonroot-amd64
|
||||
- anchore/syft:{{.Tag}}-nonroot-arm64v8
|
||||
- anchore/syft:{{.Tag}}-nonroot-ppc64le
|
||||
- anchore/syft:{{.Tag}}-nonroot-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:{{.Tag}}-nonroot
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-s390x
|
||||
|
||||
# debug images...
|
||||
- name_template: anchore/syft:debug
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}-debug-amd64
|
||||
- anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
- anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
- anchore/syft:{{.Tag}}-debug-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:debug
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-s390x
|
||||
|
||||
- name_template: anchore/syft:{{.Tag}}-debug
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}-debug-amd64
|
||||
- anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
- anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
- anchore/syft:{{.Tag}}-debug-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:{{.Tag}}-debug
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-s390x
|
||||
|
||||
sboms:
|
||||
- artifacts: archive
|
||||
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"
|
||||
- "--use-signing-config=false"
|
||||
- "--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,5 +0,0 @@
|
||||
# Code of Conduct
|
||||
|
||||
All contributors for any Anchore project must follow the [Contributor Covenant Code of Conduct](https://oss.anchore.com/docs/contributing/code-of-conduct/).
|
||||
|
||||
**TLDR:** Be kind, be respectful, and assume good intentions. We're all here to build great software together.
|
||||
+102
-7
@@ -1,13 +1,108 @@
|
||||
# Contributing
|
||||
# Contributing to Syft
|
||||
|
||||
Thank you for your interest in 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.
|
||||
|
||||
Please see the [contribution guide](https://oss.anchore.com/docs/contributing/syft/) for development requirements and helpful tips to get started developing in the repo. For a deeper dive, please see the [architecture docs](https://oss.anchore.com/docs/architecture/syft/).
|
||||
## Sign off your work
|
||||
|
||||
**Have a question or need help?** Check out our [issues and discussions guide](https://oss.anchore.com/docs/contributing/issues-and-discussions/) to find the right place to start a conversation.
|
||||
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.
|
||||
|
||||
**Ready to submit code?** Our [pull request guide](https://oss.anchore.com/docs/contributing/pull-requests/) covers everything from title conventions to the review process. Don't forget that ***all commits require a [sign-off](https://oss.anchore.com/docs/contributing/sign-off/)***.
|
||||
Signing off a commit certifies the below Developer's Certificate of Origin (DCO):
|
||||
|
||||
**Found a security issue?** Please do **not** open a public issue. Instead, see our [security policy](https://oss.anchore.com/docs/contributing/security/) for how to report vulnerabilities responsibly.
|
||||
```text
|
||||
Developer's Certificate of Origin 1.1
|
||||
|
||||
**Want to help improve the docs?** Check out the [anchore/oss-docs](https://github.com/anchore/oss-docs) repository.
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
```
|
||||
|
||||
All contributions to this project are licensed under the [Apache License Version 2.0, January 2004](http://www.apache.org/licenses/).
|
||||
|
||||
When committing your change, you can add the required line manually so that it looks like this:
|
||||
|
||||
```text
|
||||
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
|
||||
$ git commit -s -m "this is a commit message"
|
||||
```
|
||||
|
||||
To double-check that the commit was signed-off, look at the log output:
|
||||
|
||||
```text
|
||||
$ git log -1
|
||||
commit 37ceh170e4hb283bb73d958f2036ee5k07e7fde7 (HEAD -> issue-35, origin/main, main)
|
||||
Author: John Doe <john.doe@example.com>
|
||||
Date: Mon Aug 1 11:27:13 2020 -0400
|
||||
|
||||
this is a commit message
|
||||
|
||||
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. 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
|
||||
```
|
||||
|
||||
You only need to bootstrap once. After the bootstrap process, you can run the tests as many times as needed:
|
||||
|
||||
```text
|
||||
$ make unit
|
||||
$ make integration
|
||||
```
|
||||
|
||||
## Document your changes
|
||||
|
||||
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.
|
||||
+11
-12
@@ -1,4 +1,6 @@
|
||||
FROM gcr.io/distroless/static-debian12:latest AS build
|
||||
FROM alpine:latest AS build
|
||||
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
FROM scratch
|
||||
# needed for version check HTTPS request
|
||||
@@ -14,16 +16,13 @@ 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"
|
||||
LABEL org.label-schema.schema-version="1.0"
|
||||
LABEL org.label-schema.build-date=$BUILD_DATE
|
||||
LABEL org.label-schema.name="syft"
|
||||
LABEL org.label-schema.description="CLI tool and library for generating a Software Bill of Materials from container images and filesystems"
|
||||
LABEL org.label-schema.vcs-url=$VCS_URL
|
||||
LABEL org.label-schema.vcs-ref=$VCS_REF
|
||||
LABEL org.label-schema.vendor="Anchore, Inc."
|
||||
LABEL org.label-schema.version=$BUILD_VERSION
|
||||
|
||||
ENTRYPOINT ["/syft"]
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
FROM gcr.io/distroless/static-debian12:debug-nonroot
|
||||
|
||||
# create the /tmp dir, which is needed for image content cache
|
||||
WORKDIR /tmp
|
||||
|
||||
COPY syft /
|
||||
|
||||
USER nonroot
|
||||
|
||||
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,27 +0,0 @@
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
# create the /tmp dir, which is needed for image content cache
|
||||
WORKDIR /tmp
|
||||
|
||||
COPY syft /
|
||||
|
||||
USER nonroot
|
||||
|
||||
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,340 @@
|
||||
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
|
||||
ACC_TEST_IMAGE = centos:8.2.2004
|
||||
ACC_DIR = ./test/acceptance
|
||||
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)
|
||||
# the quality gate lower threshold for unit test total % coverage (by function statements)
|
||||
COVERAGE_THRESHOLD := 62
|
||||
# CI cache busting values; change these if you want CI to not use previous stored cache
|
||||
INTEGRATION_CACHE_BUSTER="88738d2f"
|
||||
CLI_CACHE_BUSTER="9a2c03cf"
|
||||
BOOTSTRAP_CACHE="c7afb99ad"
|
||||
|
||||
TOOL_DIR = .tool
|
||||
BINNY = $(TOOL_DIR)/binny
|
||||
TASK = $(TOOL_DIR)/task
|
||||
## Build variables
|
||||
DISTDIR=./dist
|
||||
SNAPSHOTDIR=./snapshot
|
||||
GITTREESTATE=$(if $(shell git status --porcelain),dirty,clean)
|
||||
OS := $(shell uname)
|
||||
|
||||
.DEFAULT_GOAL := make-default
|
||||
ifeq ($(OS),Darwin)
|
||||
SNAPSHOT_CMD=$(shell realpath $(shell pwd)/$(SNAPSHOTDIR)/$(BIN)-macos_darwin_amd64/$(BIN))
|
||||
else
|
||||
SNAPSHOT_CMD=$(shell realpath $(shell pwd)/$(SNAPSHOTDIR)/$(BIN)_linux_amd64/$(BIN))
|
||||
endif
|
||||
|
||||
## Bootstrapping targets #################################
|
||||
ifeq "$(strip $(VERSION))" ""
|
||||
override VERSION = $(shell git describe --always --tags --dirty)
|
||||
endif
|
||||
|
||||
# note: we need to assume that binny and task have not already been installed
|
||||
$(BINNY):
|
||||
@mkdir -p $(TOOL_DIR)
|
||||
@curl -sSfL https://get.anchore.io/binny | sh -s -- -b $(TOOL_DIR)
|
||||
# used to generate the changelog from the second to last tag to the current tag (used in the release pipeline when the release tag is in place)
|
||||
LAST_TAG := $(shell git describe --abbrev=0 --tags $(shell git rev-list --tags --max-count=1))
|
||||
SECOND_TO_LAST_TAG := $(shell git describe --abbrev=0 --tags $(shell git rev-list --tags --skip=1 --max-count=1))
|
||||
|
||||
# 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 ACC_DIR
|
||||
$(error ACC_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
|
||||
|
||||
## Tasks
|
||||
|
||||
.PHONY: all
|
||||
all: clean static-analysis test ## Run all linux-based checks (linting, license check, unit, integration, and linux acceptance tests)
|
||||
@printf '$(SUCCESS)All checks pass!$(RESET)\n'
|
||||
|
||||
.PHONY: test
|
||||
test: unit validate-cyclonedx-schema integration benchmark acceptance-linux cli ## Run all tests (currently unit, integration, linux acceptance, 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.2.0
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/chronicle/main/install.sh | sh -s -- -b $(TEMPDIR)/ v0.2.0-beta
|
||||
.github/scripts/goreleaser-install.sh -b $(TEMPDIR)/ v0.177.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) && NO_COLOR=1 $(TASK) -l | grep '^\* ' | cut -d' ' -f2 | tr -d ':' | tr '\n' ' '" ) $(shell bash -c "test -f $(TASK) && NO_COLOR=1 $(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:
|
||||
$(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
|
||||
|
||||
.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:
|
||||
find test/integration/test-fixtures/image-* -type f -exec md5sum {} + | awk '{print $1}' | sort | 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:
|
||||
@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: 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
|
||||
# DOCKER_CLI_EXPERIMENTAL needed to support multi architecture builds for goreleaser
|
||||
BUILD_GIT_TREE_STATE=$(GITTREESTATE) \
|
||||
DOCKER_CLI_EXPERIMENTAL=enabled \
|
||||
$(TEMPDIR)/goreleaser release --skip-publish --skip-sign --rm-dist --snapshot --config $(TEMPDIR)/goreleaser.yaml
|
||||
|
||||
# note: we cannot clean the snapshot directory since the pipeline builds the snapshot separately
|
||||
.PHONY: acceptance-mac
|
||||
acceptance-mac: $(RESULTSDIR) $(SNAPSHOTDIR) ## Run acceptance tests on build snapshot binaries and packages (Mac)
|
||||
$(call title,Running acceptance test: Run on Mac)
|
||||
$(ACC_DIR)/mac.sh \
|
||||
$(SNAPSHOTDIR) \
|
||||
$(ACC_DIR) \
|
||||
$(ACC_TEST_IMAGE) \
|
||||
$(RESULTSDIR)
|
||||
|
||||
# note: we cannot clean the snapshot directory since the pipeline builds the snapshot separately
|
||||
.PHONY: acceptance-linux
|
||||
acceptance-linux: acceptance-test-deb-package-install acceptance-test-rpm-package-install ## Run acceptance tests on build snapshot binaries and packages (Linux)
|
||||
|
||||
.PHONY: acceptance-test-deb-package-install
|
||||
acceptance-test-deb-package-install: $(RESULTSDIR) $(SNAPSHOTDIR)
|
||||
$(call title,Running acceptance test: DEB install)
|
||||
$(ACC_DIR)/deb.sh \
|
||||
$(SNAPSHOTDIR) \
|
||||
$(ACC_DIR) \
|
||||
$(ACC_TEST_IMAGE) \
|
||||
$(RESULTSDIR)
|
||||
|
||||
.PHONY: acceptance-test-rpm-package-install
|
||||
acceptance-test-rpm-package-install: $(RESULTSDIR) $(SNAPSHOTDIR)
|
||||
$(call title,Running acceptance test: RPM install)
|
||||
$(ACC_DIR)/rpm.sh \
|
||||
$(SNAPSHOTDIR) \
|
||||
$(ACC_DIR) \
|
||||
$(ACC_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:
|
||||
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_CMD)"
|
||||
$(SNAPSHOT_CMD) version
|
||||
SYFT_BINARY_LOCATION='$(SNAPSHOT_CMD)' \
|
||||
go test -count=1 -v ./test/cli
|
||||
|
||||
.PHONY: changelog-release
|
||||
changelog-release:
|
||||
$(TEMPDIR)/chronicle --since-tag $(SECOND_TO_LAST_TAG) --until-tag $(LAST_TAG) -vv > CHANGELOG.md
|
||||
|
||||
@printf '\n$(BOLD)$(CYAN)Release $(VERSION) Changelog$(RESET)\n\n'
|
||||
@cat CHANGELOG.md
|
||||
|
||||
.PHONY: changelog-unreleased
|
||||
changelog-unreleased: ## show the current changelog that will be produced on the next release (note: requires GITHUB_TOKEN set)
|
||||
$(TEMPDIR)/chronicle --since-tag $(LAST_TAG) -vv > CHANGELOG.md
|
||||
|
||||
@printf '\n$(BOLD)$(CYAN)Unreleased Changes (closed PRs and issues will not be in the final changelog)$(RESET)\n'
|
||||
|
||||
@docker run -it --rm \
|
||||
-v $(shell pwd)/CHANGELOG.md:/CHANGELOG.md \
|
||||
rawkode/mdv \
|
||||
-t 748.5989 \
|
||||
/CHANGELOG.md
|
||||
|
||||
.PHONY: release
|
||||
release: clean-dist changelog-release ## Build and publish final binaries and packages. Intended to be run only on macOS.
|
||||
$(call title,Publishing release artifacts)
|
||||
|
||||
# Prepare for macOS-specific signing process
|
||||
.github/scripts/mac-prepare-for-signing.sh
|
||||
|
||||
# login to docker
|
||||
# note: the previous step creates a new keychain, so it is important to reauth into docker.io
|
||||
@echo $${DOCKER_PASSWORD} | docker login docker.io -u $${DOCKER_USERNAME} --password-stdin
|
||||
|
||||
# create a config with the dist dir overridden
|
||||
echo "dist: $(DISTDIR)" > $(TEMPDIR)/goreleaser.yaml
|
||||
cat .goreleaser.yaml >> $(TEMPDIR)/goreleaser.yaml
|
||||
|
||||
# release (note the version transformation from v0.7.0 --> 0.7.0)
|
||||
# DOCKER_CLI_EXPERIMENTAL needed to support multi architecture builds for goreleaser
|
||||
bash -c "\
|
||||
BUILD_GIT_TREE_STATE=$(GITTREESTATE) \
|
||||
VERSION=$(VERSION:v%=%) \
|
||||
DOCKER_CLI_EXPERIMENTAL=enabled \
|
||||
$(TEMPDIR)/goreleaser \
|
||||
--rm-dist \
|
||||
--config $(TEMPDIR)/goreleaser.yaml \
|
||||
--release-notes <(cat CHANGELOG.md)"
|
||||
|
||||
# verify checksum signatures
|
||||
.github/scripts/verify-signature.sh "$(DISTDIR)"
|
||||
|
||||
# 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
|
||||
rm -rf $(RESULTSDIR)/*
|
||||
|
||||
.PHONY: clean-snapshot
|
||||
clean-snapshot:
|
||||
rm -rf $(SNAPSHOTDIR) $(TEMPDIR)/goreleaser.yaml
|
||||
|
||||
.PHONY: clean-dist
|
||||
clean-dist:
|
||||
rm -rf $(DISTDIR) $(TEMPDIR)/goreleaser.yaml
|
||||
|
||||
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,90 +2,359 @@
|
||||
<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).**
|
||||
|
||||
<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>
|
||||
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).
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
- 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, Jenkins plugins JPI/HPI, Go modules)
|
||||
- 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.
|
||||
|
||||
- Generates SBOMs for **container images**, **filesystems**, **archives** (see the docs for a full list of [supported scan targets](https://oss.anchore.com/docs/guides/sbom/scan-targets/))
|
||||
- Supports dozens of packaging ecosystems (e.g. Alpine (apk), Debian (dpkg), RPM, Go, Python, Java, JavaScript, Ruby, Rust, PHP, .NET, and [many more](https://oss.anchore.com/docs/capabilities/all-packages/))
|
||||
- Supports OCI, Docker, [Singularity](https://github.com/sylabs/singularity), and [more image formats](https://oss.anchore.com/docs/guides/sbom/scan-targets/)
|
||||
- Works seamlessly with [Grype](https://github.com/anchore/grype) for vulnerability scanning
|
||||
- Multiple output formats (**CycloneDX**, **SPDX**, **Syft JSON**, and [more](https://oss.anchore.com/docs/guides/sbom/formats/)) including the ability to [convert between SBOM formats](https://oss.anchore.com/docs/guides/sbom/conversion/)
|
||||
- Create signed SBOM attestations using the [in-toto specification](https://github.com/in-toto/attestation/blob/main/spec/README.md)
|
||||
|
||||
> [!TIP]
|
||||
> **New to Syft? Check out the [Getting Started guide](https://oss.anchore.com/docs/guides/sbom/getting-started/) for a walkthrough!**
|
||||
If you encounter an issue, please [let us know using the issue tracker](https://github.com/anchore/syft/issues).
|
||||
|
||||
## Installation
|
||||
|
||||
The quickest way to get up and going:
|
||||
### Recommended
|
||||
```bash
|
||||
curl -sSfL https://get.anchore.io/syft | sudo sh -s -- -b /usr/local/bin
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **See [Installation docs](https://oss.anchore.com/docs/installation/syft/) for more ways to get Syft, including Homebrew, Docker, Scoop, Chocolatey, Nix, and more!**
|
||||
...or, you can specify a release version and destination directory for the installation:
|
||||
|
||||
## The basics
|
||||
|
||||
See the packages within a container image or directory:
|
||||
|
||||
```bash
|
||||
# container image
|
||||
syft alpine:latest
|
||||
|
||||
# directory
|
||||
syft ./my-project
|
||||
```
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b <DESTINATION_DIR> <RELEASE_VERSION>
|
||||
```
|
||||
|
||||
To get an SBOM, specify one or more output formats:
|
||||
|
||||
### Homebrew
|
||||
```bash
|
||||
# SBOM to stdout
|
||||
syft <image> -o cyclonedx-json
|
||||
|
||||
# Multiple SBOMs to files
|
||||
syft <image> -o spdx-json=./spdx.json -o cyclonedx-json=./cdx.json
|
||||
brew tap anchore/syft
|
||||
brew install syft
|
||||
```
|
||||
|
||||
**Note**: Currently, Syft is built only for macOS and Linux.
|
||||
|
||||
> [!TIP]
|
||||
> **Check out the [Getting Started guide](https://oss.anchore.com/docs/guides/sbom/getting-started/)** to explore all of the capabilities and features.
|
||||
>
|
||||
> **Want to know all of the ins-and-outs of Syft?** Check out the [CLI docs](https://oss.anchore.com/docs/reference/syft/cli/), [configuration docs](https://oss.anchore.com/docs/reference/syft/configuration/), and [JSON schema](https://oss.anchore.com/docs/reference/syft/json/latest/).
|
||||
## Getting started
|
||||
|
||||
To generate an SBOM for a Docker or OCI image:
|
||||
```
|
||||
syft <image>
|
||||
```
|
||||
|
||||
**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)
|
||||
```
|
||||
|
||||
### Output formats
|
||||
|
||||
The output format for Syft is configurable as well:
|
||||
|
||||
```
|
||||
syft packages <image> -o <format>
|
||||
```
|
||||
|
||||
Where the `formats` available are:
|
||||
- `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.2 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).
|
||||
|
||||
## Private Registry Authentication
|
||||
|
||||
### 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).
|
||||
|
||||
|
||||
## Contributing
|
||||
An example `config.json` looks something like this:
|
||||
```
|
||||
// config.json
|
||||
{
|
||||
"auths": {
|
||||
"registry.example.com": {
|
||||
"username": "AzureDiamond",
|
||||
"password": "hunter2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
We encourage users to help make these tools better by [submitting issues](https://github.com/anchore/syft/issues) when you find a bug or want a new feature.
|
||||
Check out our [contributing overview](https://oss.anchore.com/docs/contributing/) and [developer-specific documentation](https://oss.anchore.com/docs/contributing/syft/) if you are interested in providing code contributions.
|
||||
You can run the following command as an example. It details the mount/environment configuration a container needs to access a private registry:
|
||||
|
||||
`docker run -v ./config.json:/config/config.json -e "DOCKER_CONFIG=/config" anchore/syft:latest <private_image>`
|
||||
|
||||
|
||||
### 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/">
|
||||
Syft development is sponsored by <a href="https://anchore.com/">Anchore</a>, and is released under the <a href="https://github.com/anchore/syft?tab=Apache-2.0-1-ov-file">Apache-2.0 License</a>.
|
||||
The <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`
|
||||
|
||||
For commercial support options with Syft or Grype, please [contact Anchore](https://get.anchore.com/contact/).
|
||||
|
||||
## Come talk to us!
|
||||
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
|
||||
```
|
||||
|
||||
The Syft Team holds 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!
|
||||
`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 of the SBOM report (options: table, text, json)
|
||||
# same as -o ; SYFT_OUTPUT env var
|
||||
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
|
||||
|
||||
# cataloging packages is exposed through the packages and power-user subcommands
|
||||
package:
|
||||
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: ""
|
||||
|
||||
```
|
||||
|
||||
+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.
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are applied only to the most recent release, try to always be up to date.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
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 a best effort basis, so please indicate the "urgency level" of the vulnerability as Critical, High, Medium or Low.
|
||||
|
||||
For more details, see our [security policy documentation](https://oss.anchore.com/docs/contributing/security/).
|
||||
-695
@@ -1,695 +0,0 @@
|
||||
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
generate:cpe-index: ./task.d/generate/cpe-index.yaml
|
||||
|
||||
vars:
|
||||
OWNER: anchore
|
||||
PROJECT: syft
|
||||
|
||||
# v1: when fixtures were located at test-fixtures dirs
|
||||
# v2: migration to testdata dirs
|
||||
CACHE_IMAGE: ghcr.io/{{ .OWNER }}/{{ .PROJECT }}/test-fixture-cache:v2
|
||||
|
||||
# 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" || [ "$(uname -m)" = "arm64" ] && echo "arm64_v8.0" || echo $(uname -m); }
|
||||
PROJECT_ROOT:
|
||||
sh: echo $PWD
|
||||
|
||||
# note: the snapshot dir must be a relative path starting with ./
|
||||
# e.g. when installing snapshot debs from a local path, ./ forces the deb to be installed in the current working directory instead of referencing a package name
|
||||
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: snapshot
|
||||
- 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://get.anchore.io/binny | 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-capability-drift:
|
||||
desc: Ensure there is no drift between the capabilities data and the code
|
||||
cmds:
|
||||
- .github/scripts/capability-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/testdata/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:
|
||||
- task: clean-test-observations
|
||||
- "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
|
||||
deps: [tools]
|
||||
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
|
||||
|
||||
|
||||
## Test-fixture-related targets #################################
|
||||
|
||||
fingerprints:
|
||||
desc: Generate fingerprints for all non-docker test fixture
|
||||
silent: true
|
||||
# this will look for `testdata/Makefile` and invoke the `fingerprint` target to calculate all cache input fingerprint files
|
||||
generates:
|
||||
- '**/testdata/**/*.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 'testdata'); 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 `testdata/Makefile` and invoke the `fixtures` target to generate any and all test fixtures
|
||||
cmds:
|
||||
- |
|
||||
# we want to stop on the first build error
|
||||
set -e
|
||||
|
||||
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 'testdata'); 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 "**/testdata/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 }}/testdata/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-capabilities
|
||||
- task: generate-license-list
|
||||
- task: generate-cpe-dictionary-index
|
||||
|
||||
generate-json-schema:
|
||||
desc: Generate a new JSON schema
|
||||
cmds:
|
||||
- "cd ./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 from local cache
|
||||
cmds:
|
||||
- task: generate:cpe-index:cache:pull
|
||||
- task: generate:cpe-index:cache:update
|
||||
- task: generate:cpe-index:build
|
||||
|
||||
generate-capabilities:
|
||||
desc: Generate the capabilities data file
|
||||
deps:
|
||||
- tmpdir
|
||||
- fixtures
|
||||
vars:
|
||||
# set REFRESH=true to run package tests first and refresh test observations (default: true)
|
||||
REFRESH: '{{ .REFRESH | default "true" }}'
|
||||
cmds:
|
||||
# remove all test observations prior to regenerating
|
||||
- task: clean-test-observations
|
||||
if: '{{ eq .REFRESH "true" }}'
|
||||
# this is required to update test observations; such evidence is used to update the packages/*.yaml
|
||||
- cmd: "go test ./syft/pkg/... -count=1"
|
||||
if: '{{ eq .REFRESH "true" }}'
|
||||
- "go generate ./internal/capabilities/..."
|
||||
- "gofmt -s -w ./internal/capabilities"
|
||||
# now that we have the latest capabilities, run completeness tests to ensure this is self-consistent
|
||||
# note: -p 1 forces sequential package execution to avoid race between generate (writes) and internal (reads)
|
||||
- "SYFT_ENABLE_COMPLETENESS_TESTS=true go test -p 1 ./internal/capabilities/... -count=1"
|
||||
|
||||
|
||||
## 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 }}"
|
||||
|
||||
snapshot-smoke-test:
|
||||
desc: Run a smoke test on the snapshot builds + docker images
|
||||
cmds:
|
||||
- cmd: "echo 'testing snapshot binary: {{ .SNAPSHOT_BIN }}'"
|
||||
silent: true
|
||||
- cmd: "test -f {{ .SNAPSHOT_BIN }} || (find {{ .SNAPSHOT_DIR }} && echo '\nno snapshot found for {{ .SNAPSHOT_BIN }}' && false)"
|
||||
silent: true
|
||||
- "{{ .SNAPSHOT_BIN }} version"
|
||||
- "{{ .SNAPSHOT_BIN }} scan alpine:latest"
|
||||
- docker run --rm anchore/syft:latest version
|
||||
- docker run --rm anchore/syft:latest scan alpine:latest
|
||||
|
||||
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 }}"
|
||||
|
||||
|
||||
## Cleanup targets #################################
|
||||
|
||||
clean:
|
||||
desc: Remove all cache files and old builds
|
||||
cmds:
|
||||
- task: clean-snapshot
|
||||
- task: clean-cache
|
||||
- task: clean-test-observations
|
||||
- task: clean-docker-cache
|
||||
- task: clean-oras-cache
|
||||
|
||||
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 "**/testdata/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 'testdata'); 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 }}
|
||||
|
||||
clean-test-observations:
|
||||
desc: Remove all test observations (i.e. testdata/test-observations.json)
|
||||
cmds:
|
||||
- find . -type f -wholename "**/testdata/test-observations.json" | xargs rm -f
|
||||
@@ -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,30 @@
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anchore/stereoscope"
|
||||
"github.com/anchore/syft/internal/config"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/logger"
|
||||
"github.com/anchore/syft/syft"
|
||||
"github.com/gookit/color"
|
||||
"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,
|
||||
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)
|
||||
}
|
||||
|
||||
if activeCmd == packagesCmd || activeCmd == 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)
|
||||
}
|
||||
} else {
|
||||
// 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 := logger.LogrusConfig{
|
||||
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,
|
||||
}
|
||||
|
||||
logWrapper := logger.NewLogrusLogger(cfg)
|
||||
syft.SetLogger(logWrapper)
|
||||
stereoscope.SetLogger(&logger.LogrusNestedLogger{
|
||||
Logger: logWrapper.Logger.WithField("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)
|
||||
}
|
||||
@@ -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,100 @@
|
||||
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:gocognit,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)
|
||||
if err := subscription.Unsubscribe(); err != nil {
|
||||
retErr = multierror.Append(retErr, err)
|
||||
}
|
||||
// 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.PresenterReady,
|
||||
}
|
||||
|
||||
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.PresenterReady,
|
||||
}
|
||||
|
||||
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.PresenterReady,
|
||||
Error: fmt.Errorf("unable to create presenter"),
|
||||
}
|
||||
|
||||
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.PresenterReady,
|
||||
}
|
||||
|
||||
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:
|
||||
}
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
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/formats"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/ui"
|
||||
"github.com/anchore/syft/syft"
|
||||
"github.com/anchore/syft/syft/distro"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
"github.com/anchore/syft/syft/format"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"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 (
|
||||
packagesPresenterOpt format.Option
|
||||
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) error {
|
||||
// set the presenter
|
||||
presenterOption := format.ParseOption(appConfig.Output)
|
||||
if presenterOption == format.UnknownFormatOption {
|
||||
return fmt.Errorf("bad --output value '%s'", appConfig.Output)
|
||||
}
|
||||
packagesPresenterOpt = presenterOption
|
||||
|
||||
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", source.SquashedScope.String(),
|
||||
fmt.Sprintf("selection of layers to catalog, options=%v", source.AllScopes))
|
||||
|
||||
flags.StringP(
|
||||
"output", "o", string(format.TableOption),
|
||||
fmt.Sprintf("report output formatter, options=%v", format.AllOptions),
|
||||
)
|
||||
|
||||
flags.StringP(
|
||||
"file", "", "",
|
||||
"file to write the 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.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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// could be an image or a directory, with or without a scheme
|
||||
userInput := args[0]
|
||||
|
||||
reporter, closer, err := reportWriter()
|
||||
defer func() {
|
||||
if err := closer(); err != nil {
|
||||
log.Warnf("unable to write to report destination: %+v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return eventLoop(
|
||||
packagesExecWorker(userInput),
|
||||
setupSignals(),
|
||||
eventSubscription,
|
||||
stereoscope.Cleanup,
|
||||
ui.Select(isVerbose(), appConfig.Quiet, reporter)...,
|
||||
)
|
||||
}
|
||||
|
||||
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) <-chan error {
|
||||
errs := make(chan error)
|
||||
go func() {
|
||||
defer close(errs)
|
||||
|
||||
f := formats.ByOption(packagesPresenterOpt)
|
||||
if f == nil {
|
||||
errs <- fmt.Errorf("unknown format: %s", packagesPresenterOpt)
|
||||
return
|
||||
}
|
||||
|
||||
checkForApplicationUpdate()
|
||||
|
||||
src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions())
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("failed to determine image source: %w", err)
|
||||
return
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
catalog, relationships, d, err := syft.CatalogPackages(src, appConfig.Package.Cataloger.ScopeOpt)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("failed to catalog input: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
if appConfig.Anchore.Host != "" {
|
||||
if err := runPackageSbomUpload(src, src.Metadata, catalog, d); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sbomResult := sbom.SBOM{
|
||||
Artifacts: sbom.Artifacts{
|
||||
PackageCatalog: catalog,
|
||||
Distro: d,
|
||||
},
|
||||
Relationships: relationships,
|
||||
Source: src.Metadata,
|
||||
}
|
||||
|
||||
bus.Publish(partybus.Event{
|
||||
Type: event.PresenterReady,
|
||||
Value: f.Presenter(sbomResult),
|
||||
})
|
||||
}()
|
||||
return errs
|
||||
}
|
||||
|
||||
func runPackageSbomUpload(src *source.Source, s source.Metadata, catalog *pkg.Catalog, d *distro.Distro) 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,
|
||||
SourceMetadata: s,
|
||||
Catalog: catalog,
|
||||
Distro: d,
|
||||
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,170 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
|
||||
"github.com/anchore/stereoscope"
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/bus"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/presenter/poweruser"
|
||||
"github.com/anchore/syft/internal/ui"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
"github.com/pkg/profile"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
)
|
||||
|
||||
const powerUserExample = ` {{.appName}} {{.command}} <image>
|
||||
|
||||
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]
|
||||
|
||||
reporter, closer, err := reportWriter()
|
||||
defer func() {
|
||||
if err := closer(); err != nil {
|
||||
log.Warnf("unable to write to report destination: %+v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return eventLoop(
|
||||
powerUserExecWorker(userInput),
|
||||
setupSignals(),
|
||||
eventSubscription,
|
||||
stereoscope.Cleanup,
|
||||
ui.Select(isVerbose(), appConfig.Quiet, reporter)...,
|
||||
)
|
||||
}
|
||||
func powerUserExecWorker(userInput string) <-chan error {
|
||||
errs := make(chan error)
|
||||
go func() {
|
||||
defer close(errs)
|
||||
|
||||
tasks, err := powerUserTasks()
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
|
||||
checkForApplicationUpdate()
|
||||
|
||||
src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions())
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
s := sbom.SBOM{
|
||||
Source: src.Metadata,
|
||||
}
|
||||
|
||||
var results []<-chan artifact.Relationship
|
||||
for _, task := range tasks {
|
||||
c := make(chan artifact.Relationship)
|
||||
results = append(results, c)
|
||||
|
||||
go runTask(task, &s.Artifacts, src, c, errs)
|
||||
}
|
||||
|
||||
for relationship := range mergeResults(results...) {
|
||||
s.Relationships = append(s.Relationships, relationship)
|
||||
}
|
||||
|
||||
bus.Publish(partybus.Event{
|
||||
Type: event.PresenterReady,
|
||||
Value: poweruser.NewJSONPresenter(s, *appConfig),
|
||||
})
|
||||
}()
|
||||
return errs
|
||||
}
|
||||
|
||||
func runTask(t powerUserTask, a *sbom.Artifacts, src *source.Source, c chan<- artifact.Relationship, errs chan<- error) {
|
||||
defer close(c)
|
||||
|
||||
relationships, err := t(a, src)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
|
||||
for _, relationship := range relationships {
|
||||
c <- relationship
|
||||
}
|
||||
}
|
||||
|
||||
func mergeResults(cs ...<-chan artifact.Relationship) <-chan artifact.Relationship {
|
||||
var wg sync.WaitGroup
|
||||
var results = make(chan artifact.Relationship)
|
||||
|
||||
wg.Add(len(cs))
|
||||
for _, c := range cs {
|
||||
go func(c <-chan artifact.Relationship) {
|
||||
for n := range c {
|
||||
results <- n
|
||||
}
|
||||
wg.Done()
|
||||
}(c)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"fmt"
|
||||
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
|
||||
"github.com/anchore/syft/syft"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
type powerUserTask func(*sbom.Artifacts, *source.Source) ([]artifact.Relationship, error)
|
||||
|
||||
func powerUserTasks() ([]powerUserTask, error) {
|
||||
var tasks []powerUserTask
|
||||
|
||||
generators := []func() (powerUserTask, error){
|
||||
catalogPackagesTask,
|
||||
catalogFileMetadataTask,
|
||||
catalogFileDigestsTask,
|
||||
catalogSecretsTask,
|
||||
catalogFileClassificationsTask,
|
||||
catalogContentsTask,
|
||||
}
|
||||
|
||||
for _, generator := range generators {
|
||||
task, err := generator()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if task != nil {
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
}
|
||||
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func catalogPackagesTask() (powerUserTask, error) {
|
||||
if !appConfig.Package.Cataloger.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
task := func(results *sbom.Artifacts, src *source.Source) ([]artifact.Relationship, error) {
|
||||
packageCatalog, relationships, theDistro, err := syft.CatalogPackages(src, appConfig.Package.Cataloger.ScopeOpt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results.PackageCatalog = packageCatalog
|
||||
results.Distro = theDistro
|
||||
|
||||
return relationships, nil
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func catalogFileMetadataTask() (powerUserTask, error) {
|
||||
if !appConfig.FileMetadata.Cataloger.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
metadataCataloger := file.NewMetadataCataloger()
|
||||
|
||||
task := func(results *sbom.Artifacts, src *source.Source) ([]artifact.Relationship, error) {
|
||||
resolver, err := src.FileResolver(appConfig.FileMetadata.Cataloger.ScopeOpt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := metadataCataloger.Catalog(resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results.FileMetadata = result
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func catalogFileDigestsTask() (powerUserTask, error) {
|
||||
if !appConfig.FileMetadata.Cataloger.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
supportedHashAlgorithms := make(map[string]crypto.Hash)
|
||||
for _, h := range []crypto.Hash{
|
||||
crypto.MD5,
|
||||
crypto.SHA1,
|
||||
crypto.SHA256,
|
||||
} {
|
||||
supportedHashAlgorithms[file.DigestAlgorithmName(h)] = h
|
||||
}
|
||||
|
||||
var hashes []crypto.Hash
|
||||
for _, hashStr := range appConfig.FileMetadata.Digests {
|
||||
name := file.CleanDigestAlgorithmName(hashStr)
|
||||
hashObj, ok := supportedHashAlgorithms[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported hash algorithm: %s", hashStr)
|
||||
}
|
||||
hashes = append(hashes, hashObj)
|
||||
}
|
||||
|
||||
digestsCataloger, err := file.NewDigestsCataloger(hashes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
task := func(results *sbom.Artifacts, src *source.Source) ([]artifact.Relationship, error) {
|
||||
resolver, err := src.FileResolver(appConfig.FileMetadata.Cataloger.ScopeOpt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := digestsCataloger.Catalog(resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results.FileDigests = result
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func catalogSecretsTask() (powerUserTask, error) {
|
||||
if !appConfig.Secrets.Cataloger.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
patterns, err := file.GenerateSearchPatterns(file.DefaultSecretsPatterns, appConfig.Secrets.AdditionalPatterns, appConfig.Secrets.ExcludePatternNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretsCataloger, err := file.NewSecretsCataloger(patterns, appConfig.Secrets.RevealValues, appConfig.Secrets.SkipFilesAboveSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
task := func(results *sbom.Artifacts, src *source.Source) ([]artifact.Relationship, error) {
|
||||
resolver, err := src.FileResolver(appConfig.Secrets.Cataloger.ScopeOpt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := secretsCataloger.Catalog(resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results.Secrets = result
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func catalogFileClassificationsTask() (powerUserTask, error) {
|
||||
if !appConfig.FileClassification.Cataloger.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TODO: in the future we could expose out the classifiers via configuration
|
||||
classifierCataloger, err := file.NewClassificationCataloger(file.DefaultClassifiers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
task := func(results *sbom.Artifacts, src *source.Source) ([]artifact.Relationship, error) {
|
||||
resolver, err := src.FileResolver(appConfig.FileClassification.Cataloger.ScopeOpt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := classifierCataloger.Catalog(resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results.FileClassifications = result
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func catalogContentsTask() (powerUserTask, error) {
|
||||
if !appConfig.FileContents.Cataloger.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
contentsCataloger, err := file.NewContentsCataloger(appConfig.FileContents.Globs, appConfig.FileContents.SkipFilesAboveSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
task := func(results *sbom.Artifacts, src *source.Source) ([]artifact.Relationship, error) {
|
||||
resolver, err := src.FileResolver(appConfig.FileContents.Cataloger.ScopeOpt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := contentsCataloger.Catalog(resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results.FileContents = result
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
)
|
||||
|
||||
func reportWriter() (io.Writer, func() error, error) {
|
||||
nop := func() error { return nil }
|
||||
path := strings.TrimSpace(appConfig.File)
|
||||
|
||||
switch len(path) {
|
||||
case 0:
|
||||
return os.Stdout, nop, nil
|
||||
|
||||
default:
|
||||
reportFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
|
||||
if err != nil {
|
||||
return nil, nop, fmt.Errorf("unable to create report file: %w", err)
|
||||
}
|
||||
|
||||
return reportFile, func() error {
|
||||
log.Infof("report written to file=%q", path)
|
||||
|
||||
return reportFile.Close()
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/anchore/syft/internal/config"
|
||||
"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,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// set universal flags
|
||||
rootCmd.PersistentFlags().StringVarP(&persistentOpts.ConfigPath, "config", "c", "", "application config file")
|
||||
|
||||
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,66 +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"
|
||||
constants "github.com/anchore/syft/internal"
|
||||
)
|
||||
|
||||
// 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, schemaVersion),
|
||||
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
|
||||
}
|
||||
|
||||
func schemaVersion() (string, any) {
|
||||
return "SchemaVersion", constants.JSONSchemaVersion
|
||||
}
|
||||
@@ -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_handlePullSourceStarted/snap_download_in_progress - 1]
|
||||
⠋ Downloading snap file... ━━━━━━━━━━━━━━━━━━━━ example-app_1.0_amd64.snap
|
||||
---
|
||||
|
||||
[TestHandler_handlePullSourceStarted/snap_download_complete - 1]
|
||||
✔ Snap downloaded successfully example-app_1.0_amd64.snap
|
||||
---
|
||||
@@ -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 {
|
||||
fmt.Fprintf(&sb, " %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.TaskProgress{
|
||||
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.TaskProgress{
|
||||
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.TaskProgress{
|
||||
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.TaskProgress{
|
||||
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.TaskProgress{
|
||||
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.TaskProgress{
|
||||
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,37 +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) handlePullSourceStarted(e partybus.Event) []tea.Model {
|
||||
prog, info, err := syftEventParsers.ParsePullSourceStarted(e)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Debug("unable to parse event")
|
||||
return nil
|
||||
}
|
||||
|
||||
tsk := m.newTaskProgress(
|
||||
taskprogress.Title{
|
||||
Default: info.Title.Default,
|
||||
Running: info.Title.WhileRunning,
|
||||
Success: info.Title.OnSuccess,
|
||||
},
|
||||
taskprogress.WithStagedProgressable(prog),
|
||||
)
|
||||
|
||||
tsk.HideOnSuccess = info.HideOnSuccess
|
||||
tsk.HideStageOnSuccess = info.HideStageOnSuccess
|
||||
tsk.HideProgressOnSuccess = true
|
||||
|
||||
if info.Context != "" {
|
||||
tsk.Context = []string{info.Context}
|
||||
}
|
||||
|
||||
return []tea.Model{tsk}
|
||||
}
|
||||
@@ -1,121 +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"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
"github.com/anchore/syft/syft/event/monitor"
|
||||
)
|
||||
|
||||
func TestHandler_handlePullSourceStarted(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
eventFn func(*testing.T) partybus.Event
|
||||
iterations int
|
||||
}{
|
||||
{
|
||||
name: "snap download in progress",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
stage := progress.NewAtomicStage("")
|
||||
manual := progress.NewManual(0)
|
||||
manual.SetTotal(1000000) // 1MB file
|
||||
manual.Set(250000) // 25% downloaded
|
||||
|
||||
taskProg := &monitor.TaskProgress{
|
||||
AtomicStage: stage,
|
||||
Manual: manual,
|
||||
}
|
||||
|
||||
genericTask := monitor.GenericTask{
|
||||
Title: monitor.Title{
|
||||
Default: "Downloading snap",
|
||||
WhileRunning: "Downloading snap file...",
|
||||
OnSuccess: "Snap downloaded",
|
||||
},
|
||||
Context: "example-app_1.0_amd64.snap",
|
||||
HideOnSuccess: false,
|
||||
HideStageOnSuccess: true,
|
||||
ID: "snap-download-123",
|
||||
}
|
||||
|
||||
return partybus.Event{
|
||||
Type: event.PullSourceStarted,
|
||||
Source: genericTask,
|
||||
Value: taskProg,
|
||||
}
|
||||
},
|
||||
iterations: 5,
|
||||
},
|
||||
{
|
||||
name: "snap download complete",
|
||||
eventFn: func(t *testing.T) partybus.Event {
|
||||
stage := progress.NewAtomicStage("")
|
||||
manual := progress.NewManual(0)
|
||||
manual.SetTotal(1000000) // 1MB file
|
||||
manual.Set(1000000) // 100% downloaded
|
||||
manual.SetCompleted()
|
||||
|
||||
taskProg := &monitor.TaskProgress{
|
||||
AtomicStage: stage,
|
||||
Manual: manual,
|
||||
}
|
||||
|
||||
genericTask := monitor.GenericTask{
|
||||
Title: monitor.Title{
|
||||
Default: "Downloading snap",
|
||||
WhileRunning: "Downloading snap file...",
|
||||
OnSuccess: "Snap downloaded successfully",
|
||||
},
|
||||
Context: "example-app_1.0_amd64.snap",
|
||||
HideOnSuccess: false,
|
||||
HideStageOnSuccess: true,
|
||||
ID: "snap-download-123",
|
||||
}
|
||||
|
||||
return partybus.Event{
|
||||
Type: event.PullSourceStarted,
|
||||
Source: genericTask,
|
||||
Value: taskProg,
|
||||
}
|
||||
},
|
||||
iterations: 3,
|
||||
},
|
||||
}
|
||||
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.handlePullSourceStarted(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,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,81 +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.PullSourceStarted: simpleHandler(h.handlePullSourceStarted),
|
||||
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.Nested("from", "stereoscope"))
|
||||
return nil
|
||||
},
|
||||
).
|
||||
WithPostRuns(func(_ *clio.State, _ error) {
|
||||
stereoscope.Cleanup() //nolint:staticcheck // we don't have access to the image object here
|
||||
})
|
||||
return clioCfg
|
||||
}
|
||||
@@ -1,282 +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"
|
||||
jsonFormat = "json"
|
||||
)
|
||||
|
||||
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 is omitted (defaults to Enabled: false, explicitly not allowed for attestation)
|
||||
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", jsonFormat:
|
||||
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,31 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"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),
|
||||
)
|
||||
|
||||
// only add cataloger info command if experimental capabilities feature is enabled
|
||||
if isCapabilitiesExperimentEnabled() {
|
||||
cmd.AddCommand(CatalogerCaps(app))
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func isCapabilitiesExperimentEnabled() bool {
|
||||
return os.Getenv("SYFT_EXP_CAPABILITIES") == "true"
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/olekukonko/tablewriter/renderer"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
"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/capabilities"
|
||||
"github.com/anchore/syft/internal/task"
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
_ "github.com/anchore/syft/syft/pkg/cataloger" // register all cataloger capabilities, otherwise the info command will not function
|
||||
)
|
||||
|
||||
var (
|
||||
yesStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // hi green
|
||||
noStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark grey
|
||||
criteriaWidth = 60
|
||||
criteriaMargin = 10
|
||||
)
|
||||
|
||||
// types for JSON cataloger info output
|
||||
type (
|
||||
configFieldInfo struct {
|
||||
Key string `json:"key"`
|
||||
Description string `json:"description"`
|
||||
AppKey string `json:"app_key,omitempty"`
|
||||
}
|
||||
|
||||
configInfo struct {
|
||||
Type string `json:"type"`
|
||||
Fields []configFieldInfo `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
detectorPackageInfo struct {
|
||||
Class string `json:"class"`
|
||||
Name string `json:"name"`
|
||||
PURL string `json:"purl"`
|
||||
CPEs []string `json:"cpes"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
patternInfo struct {
|
||||
Method string `json:"method"`
|
||||
Criteria []string `json:"criteria"`
|
||||
Conditions []capabilities.DetectorCondition `json:"conditions,omitempty"`
|
||||
Packages []detectorPackageInfo `json:"packages,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
PackageTypes []string `json:"package_types,omitempty"`
|
||||
JSONSchemaTypes []string `json:"json_schema_types,omitempty"`
|
||||
Capabilities capabilities.CapabilitySet `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
catalogerInfo struct {
|
||||
Ecosystem string `json:"ecosystem,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Selectors []string `json:"selectors,omitempty"`
|
||||
Deprecated bool `json:"deprecated,omitempty"`
|
||||
Patterns []patternInfo `json:"patterns,omitempty"`
|
||||
Capabilities capabilities.CapabilitySet `json:"capabilities,omitempty"`
|
||||
Config *configInfo `json:"config,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
type catalogerInfoOptions struct {
|
||||
Output string `yaml:"output" json:"output" mapstructure:"output"`
|
||||
options.CatalogerSelection `yaml:",inline" json:",inline" mapstructure:",squash"`
|
||||
Names []string // cataloger names from args
|
||||
}
|
||||
|
||||
func (o *catalogerInfoOptions) setNames(args []string) error {
|
||||
o.Names = args
|
||||
|
||||
usingLegacyCatalogers := len(o.Catalogers) > 0
|
||||
usingNewCatalogers := len(o.DefaultCatalogers) > 0 || len(o.SelectCatalogers) > 0
|
||||
usingSelection := usingNewCatalogers || usingLegacyCatalogers
|
||||
|
||||
if usingSelection && len(o.Names) > 0 {
|
||||
return fmt.Errorf("cannot use both cataloger name arguments and '--catalogers'/'--select-catalogers'/'--default-catalogers' flags")
|
||||
}
|
||||
|
||||
if usingSelection {
|
||||
// get all available package cataloger tasks
|
||||
pkgTaskFactories := task.DefaultPackageTaskFactories()
|
||||
allPkgTasks, err := pkgTaskFactories.Tasks(task.DefaultCatalogingFactoryConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create pkg cataloger tasks: %w", err)
|
||||
}
|
||||
|
||||
// make the selection based on user input
|
||||
defaultCatalogers := options.FlattenAndSort(o.DefaultCatalogers)
|
||||
selectCatalogers := options.FlattenAndSort(o.SelectCatalogers)
|
||||
selectedTaskGroups, _, err := task.SelectInGroups(
|
||||
[][]task.Task{allPkgTasks},
|
||||
cataloging.NewSelectionRequest().
|
||||
WithDefaults(defaultCatalogers...).
|
||||
WithExpression(selectCatalogers...),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to select catalogers: %w", err)
|
||||
}
|
||||
|
||||
// build the list of cataloger names based on the selection
|
||||
for _, g := range selectedTaskGroups {
|
||||
for _, t := range g {
|
||||
o.Names = append(o.Names, t.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *catalogerInfoOptions) AddFlags(flags clio.FlagSet) {
|
||||
flags.StringVarP(&o.Output, "output", "o", "format to output the cataloger info (available: table, json)")
|
||||
}
|
||||
|
||||
func defaultCatalogerCapsOptions() *catalogerInfoOptions {
|
||||
return &catalogerInfoOptions{
|
||||
CatalogerSelection: options.CatalogerSelection{
|
||||
// this is different than the default behavior where a scan will automatically detect the default set
|
||||
DefaultCatalogers: []string{"all"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func CatalogerCaps(app clio.Application) *cobra.Command {
|
||||
opts := defaultCatalogerCapsOptions()
|
||||
|
||||
return app.SetupCommand(&cobra.Command{
|
||||
Use: "info [OPTIONS] [CATALOGER_NAMES...]",
|
||||
Short: "Show detailed capabilities of catalogers",
|
||||
Args: cobra.ArbitraryArgs,
|
||||
PreRunE: disableUI(app, os.Stdout),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
if err := opts.setNames(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runCatalogerInfo(opts)
|
||||
},
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func runCatalogerInfo(opts *catalogerInfoOptions) error {
|
||||
doc, err := capabilities.LoadDocument()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to load cataloger capabilities: %w", err)
|
||||
}
|
||||
|
||||
// filter by cataloger names if provided
|
||||
catalogers := doc.Catalogers
|
||||
if len(opts.Names) > 0 {
|
||||
catalogers = filterCatalogersByName(catalogers, opts.Names)
|
||||
}
|
||||
|
||||
report, err := catalogerInfoReport(opts, doc, catalogers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to generate cataloger info report: %w", err)
|
||||
}
|
||||
|
||||
bus.Report(report)
|
||||
bus.Notify("Note: the `cataloger info` command is experimental and may change or be removed without notice. Do not depend on its output in production systems.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterCatalogersByName(catalogers []capabilities.CatalogerEntry, names []string) []capabilities.CatalogerEntry {
|
||||
nameSet := make(map[string]bool)
|
||||
for _, name := range names {
|
||||
nameSet[name] = true
|
||||
}
|
||||
|
||||
var filtered []capabilities.CatalogerEntry
|
||||
for _, cat := range catalogers {
|
||||
if nameSet[cat.Name] {
|
||||
filtered = append(filtered, cat)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func catalogerInfoReport(opts *catalogerInfoOptions, doc *capabilities.Document, catalogers []capabilities.CatalogerEntry) (string, error) {
|
||||
switch opts.Output {
|
||||
case jsonFormat:
|
||||
return renderCatalogerInfoJSON(doc, catalogers)
|
||||
case "table", "":
|
||||
return renderCatalogerInfoTable(doc, catalogers), nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid output format %q, must be one of: table, json", opts.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func renderCatalogerInfoJSON(doc *capabilities.Document, catalogers []capabilities.CatalogerEntry) (string, error) {
|
||||
type document struct {
|
||||
Catalogers []catalogerInfo `json:"catalogers"`
|
||||
}
|
||||
|
||||
docOut := document{}
|
||||
|
||||
// sort catalogers by ecosystem then name
|
||||
sort.Slice(catalogers, func(i, j int) bool {
|
||||
if catalogers[i].Ecosystem != catalogers[j].Ecosystem {
|
||||
return catalogers[i].Ecosystem < catalogers[j].Ecosystem
|
||||
}
|
||||
return catalogers[i].Name < catalogers[j].Name
|
||||
})
|
||||
|
||||
for _, cat := range catalogers {
|
||||
info := catalogerInfo{
|
||||
Ecosystem: cat.Ecosystem,
|
||||
Name: cat.Name,
|
||||
Type: cat.Type,
|
||||
Selectors: cat.Selectors,
|
||||
Deprecated: isDeprecatedCataloger(cat.Selectors),
|
||||
}
|
||||
|
||||
// convert parsers to patterns if available
|
||||
info.Patterns = convertParsersToPatterns(cat.Parsers)
|
||||
|
||||
// if no parsers, use detectors instead
|
||||
if len(info.Patterns) == 0 {
|
||||
info.Capabilities = cat.Capabilities
|
||||
info.Patterns = convertDetectorsToPatterns(cat.Detectors, cat.PackageTypes, cat.JSONSchemaTypes)
|
||||
}
|
||||
|
||||
info.Config = getConfigInfoFromDocument(doc, cat.Config)
|
||||
|
||||
docOut.Catalogers = append(docOut.Catalogers, info)
|
||||
}
|
||||
|
||||
by, err := json.Marshal(docOut)
|
||||
return string(by), err
|
||||
}
|
||||
|
||||
// isDeprecatedCataloger checks if a cataloger is deprecated based on its selectors
|
||||
func isDeprecatedCataloger(selectors []string) bool {
|
||||
for _, selector := range selectors {
|
||||
if selector == "deprecated" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// convertDetectorPackages converts detector package info to the JSON output format
|
||||
func convertDetectorPackages(pkgs []capabilities.DetectorPackageInfo) []detectorPackageInfo {
|
||||
var result []detectorPackageInfo
|
||||
for _, pkg := range pkgs {
|
||||
result = append(result, detectorPackageInfo{
|
||||
Class: pkg.Class,
|
||||
Name: pkg.Name,
|
||||
PURL: pkg.PURL,
|
||||
CPEs: pkg.CPEs,
|
||||
Type: pkg.Type,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// convertParsersToPatterns converts parser entries to pattern info for JSON output
|
||||
func convertParsersToPatterns(parsers []capabilities.Parser) []patternInfo {
|
||||
var patterns []patternInfo
|
||||
for _, parser := range parsers {
|
||||
patterns = append(patterns, patternInfo{
|
||||
Method: string(parser.Detector.Method),
|
||||
Criteria: parser.Detector.Criteria,
|
||||
Conditions: parser.Detector.Conditions,
|
||||
Packages: convertDetectorPackages(parser.Detector.Packages),
|
||||
Comment: parser.Detector.Comment,
|
||||
PackageTypes: parser.PackageTypes,
|
||||
JSONSchemaTypes: parser.JSONSchemaTypes,
|
||||
Capabilities: parser.Capabilities,
|
||||
})
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
// convertDetectorsToPatterns converts detector entries to pattern info for JSON output (for non-parser catalogers)
|
||||
func convertDetectorsToPatterns(detectors []capabilities.Detector, packageTypes, jsonSchemaTypes []string) []patternInfo {
|
||||
var patterns []patternInfo
|
||||
for _, det := range detectors {
|
||||
patterns = append(patterns, patternInfo{
|
||||
Method: string(det.Method),
|
||||
Criteria: det.Criteria,
|
||||
Conditions: det.Conditions,
|
||||
Packages: convertDetectorPackages(det.Packages),
|
||||
Comment: det.Comment,
|
||||
PackageTypes: packageTypes,
|
||||
JSONSchemaTypes: jsonSchemaTypes,
|
||||
})
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
// getConfigInfoFromDocument retrieves config info from the capabilities document
|
||||
func getConfigInfoFromDocument(doc *capabilities.Document, configType string) *configInfo {
|
||||
if configType == "" {
|
||||
return nil
|
||||
}
|
||||
configEntry, ok := doc.Configs[configType]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
cfg := &configInfo{
|
||||
Type: configType,
|
||||
}
|
||||
for _, field := range configEntry.Fields {
|
||||
cfg.Fields = append(cfg.Fields, configFieldInfo{
|
||||
Key: field.Key,
|
||||
Description: field.Description,
|
||||
AppKey: field.AppKey,
|
||||
})
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func renderCatalogerInfoTable(_ *capabilities.Document, catalogers []capabilities.CatalogerEntry) string {
|
||||
if len(catalogers) == 0 {
|
||||
return noStyle.Render("No catalogers found")
|
||||
}
|
||||
|
||||
// sort catalogers by ecosystem then name
|
||||
sort.Slice(catalogers, func(i, j int) bool {
|
||||
if catalogers[i].Ecosystem != catalogers[j].Ecosystem {
|
||||
return catalogers[i].Ecosystem < catalogers[j].Ecosystem
|
||||
}
|
||||
return catalogers[i].Name < catalogers[j].Name
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
// create table with hierarchical merging
|
||||
table := tablewriter.NewTable(&buf,
|
||||
tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{
|
||||
Settings: tw.Settings{Separators: tw.Separators{BetweenRows: tw.On}},
|
||||
})),
|
||||
tablewriter.WithConfig(tablewriter.Config{
|
||||
Row: tw.CellConfig{
|
||||
Formatting: tw.CellFormatting{MergeMode: tw.MergeHierarchical},
|
||||
Alignment: tw.CellAlignment{Global: tw.AlignLeft},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// set headers
|
||||
table.Header("ECOSYSTEM", "CATALOGER", "CRITERIA", "LICENSE", "NODES", "EDGES", "KINDS", "LISTING", "DIGESTS", "HASH")
|
||||
|
||||
// build rows for each cataloger
|
||||
var data [][]string
|
||||
for _, cat := range catalogers {
|
||||
ecosystem := cat.Ecosystem
|
||||
if ecosystem == "" {
|
||||
ecosystem = "other"
|
||||
}
|
||||
|
||||
if cat.Type == "generic" {
|
||||
// generic catalogers: one row per parser
|
||||
for _, parser := range cat.Parsers {
|
||||
criteria := formatCriteria([]capabilities.Detector{parser.Detector})
|
||||
row := buildTableRowFromCapabilities(ecosystem, cat.Name, criteria, parser.Capabilities)
|
||||
data = append(data, row)
|
||||
}
|
||||
} else {
|
||||
// custom catalogers: one row with all detectors
|
||||
criteria := formatCriteria(cat.Detectors)
|
||||
row := buildTableRowFromCapabilities(ecosystem, cat.Name, criteria, cat.Capabilities)
|
||||
data = append(data, row)
|
||||
}
|
||||
}
|
||||
|
||||
// add all rows at once for better merging
|
||||
_ = table.Bulk(data)
|
||||
_ = table.Render()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// buildTableRowFromCapabilities builds a table row from capability values
|
||||
func buildTableRowFromCapabilities(ecosystem, name, criteria string, caps capabilities.CapabilitySet) []string {
|
||||
// extract capability default values
|
||||
license := extractBoolCapability(caps, "license")
|
||||
nodes := extractNodesCapability(caps)
|
||||
edges := extractStringCapability(caps, "dependency.edges")
|
||||
kinds := extractArrayCapability(caps, "dependency.kinds")
|
||||
listing := extractBoolCapability(caps, "package_manager.files.listing")
|
||||
digests := extractBoolCapability(caps, "package_manager.files.digests")
|
||||
hash := extractBoolCapability(caps, "package_manager.package_integrity_hash")
|
||||
|
||||
return []string{
|
||||
ecosystem,
|
||||
name,
|
||||
criteria,
|
||||
license,
|
||||
nodes,
|
||||
edges,
|
||||
kinds,
|
||||
listing,
|
||||
digests,
|
||||
hash,
|
||||
}
|
||||
}
|
||||
|
||||
// extractBoolCapability extracts a boolean capability value and formats it
|
||||
func extractBoolCapability(caps capabilities.CapabilitySet, name string) string {
|
||||
for _, cap := range caps {
|
||||
if cap.Name == name {
|
||||
if b, ok := cap.Default.(bool); ok {
|
||||
if b {
|
||||
return yesStyle.Render("✔")
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
|
||||
// extractStringCapability extracts a string capability value
|
||||
func extractStringCapability(caps capabilities.CapabilitySet, name string) string {
|
||||
for _, cap := range caps {
|
||||
if cap.Name == name {
|
||||
if s, ok := cap.Default.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
|
||||
// extractArrayCapability extracts an array capability value and formats it as comma-separated
|
||||
func extractArrayCapability(caps capabilities.CapabilitySet, name string) string {
|
||||
for _, cap := range caps {
|
||||
if cap.Name == name {
|
||||
// handle various array types
|
||||
switch v := cap.Default.(type) {
|
||||
case []string:
|
||||
if len(v) > 0 {
|
||||
return strings.Join(v, ", ")
|
||||
}
|
||||
case []interface{}:
|
||||
if len(v) > 0 {
|
||||
strs := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
strs = append(strs, fmt.Sprintf("%v", item))
|
||||
}
|
||||
return strings.Join(strs, ", ")
|
||||
}
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
|
||||
// extractNodesCapability extracts dependency.depth and formats it
|
||||
// maps ["direct", "indirect"] to "transitive"
|
||||
func extractNodesCapability(caps capabilities.CapabilitySet) string {
|
||||
for _, cap := range caps {
|
||||
if cap.Name == "dependency.depth" {
|
||||
switch v := cap.Default.(type) {
|
||||
case []string:
|
||||
return formatDepthStringArray(v)
|
||||
case []interface{}:
|
||||
return formatDepthInterfaceArray(v)
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
|
||||
// formatDepthStringArray formats a []string dependency depth value
|
||||
func formatDepthStringArray(v []string) string {
|
||||
if len(v) == 0 {
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
if hasBothDirectAndIndirect(v) {
|
||||
return "transitive"
|
||||
}
|
||||
return strings.Join(v, ", ")
|
||||
}
|
||||
|
||||
// formatDepthInterfaceArray formats a []interface{} dependency depth value
|
||||
func formatDepthInterfaceArray(v []interface{}) string {
|
||||
if len(v) == 0 {
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
strs := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
strs = append(strs, fmt.Sprintf("%v", item))
|
||||
}
|
||||
if hasBothDirectAndIndirect(strs) {
|
||||
return "transitive"
|
||||
}
|
||||
return strings.Join(strs, ", ")
|
||||
}
|
||||
|
||||
// hasBothDirectAndIndirect checks if a slice contains both "direct" and "indirect" strings
|
||||
func hasBothDirectAndIndirect(items []string) bool {
|
||||
hasDirect := false
|
||||
hasIndirect := false
|
||||
for _, item := range items {
|
||||
if item == "direct" {
|
||||
hasDirect = true
|
||||
}
|
||||
if item == "indirect" {
|
||||
hasIndirect = true
|
||||
}
|
||||
}
|
||||
return hasDirect && hasIndirect
|
||||
}
|
||||
|
||||
func formatCriteria(detectors []capabilities.Detector) string {
|
||||
var allCriteria []string
|
||||
methods := strset.New()
|
||||
|
||||
for _, det := range detectors {
|
||||
allCriteria = append(allCriteria, det.Criteria...)
|
||||
methods.Add(string(det.Method))
|
||||
}
|
||||
|
||||
// smart word wrapping: wrap by word (criterion) up to criteriaWidth,
|
||||
// allow up to criteriaWidth+criteriaMargin before forcing a new line
|
||||
var lines []string
|
||||
var currentLine []string
|
||||
currentLength := 0
|
||||
|
||||
for _, criterion := range allCriteria {
|
||||
// calculate length including the criterion plus ", " separator (except for first item)
|
||||
itemLength := len(criterion)
|
||||
if len(currentLine) > 0 {
|
||||
itemLength += 2 // for ", "
|
||||
}
|
||||
|
||||
newLength := currentLength + itemLength
|
||||
|
||||
if len(currentLine) > 0 && newLength > criteriaWidth {
|
||||
// check if it's within margin
|
||||
if newLength <= criteriaWidth+criteriaMargin {
|
||||
// close enough, add it anyway
|
||||
currentLine = append(currentLine, criterion)
|
||||
currentLength = newLength
|
||||
} else {
|
||||
// too long, finalize current line and start new one
|
||||
lines = append(lines, strings.Join(currentLine, ", "))
|
||||
currentLine = []string{criterion}
|
||||
currentLength = len(criterion)
|
||||
}
|
||||
} else {
|
||||
// fits within width or first item
|
||||
currentLine = append(currentLine, criterion)
|
||||
currentLength = newLength
|
||||
}
|
||||
}
|
||||
|
||||
// add final line
|
||||
if len(currentLine) > 0 {
|
||||
lines = append(lines, strings.Join(currentLine, ", "))
|
||||
}
|
||||
|
||||
methodsList := methods.List()
|
||||
sort.Strings(methodsList)
|
||||
method := strings.Join(methodsList, ", ")
|
||||
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
joined := strings.Join(lines, "\n")
|
||||
if method != string(capabilities.GlobDetection) {
|
||||
return fmt.Sprintf("%s (%s)", joined, method)
|
||||
}
|
||||
return joined
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/internal/capabilities"
|
||||
)
|
||||
|
||||
func Test_isDeprecatedCataloger(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
selectors []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty selectors",
|
||||
selectors: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no deprecated selector",
|
||||
selectors: []string{"python", "pip", "package"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "has deprecated selector",
|
||||
selectors: []string{"python", "deprecated", "package"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "only deprecated selector",
|
||||
selectors: []string{"deprecated"},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isDeprecatedCataloger(tt.selectors)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_hasBothDirectAndIndirect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty slice",
|
||||
items: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "only direct",
|
||||
items: []string{"direct"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "only indirect",
|
||||
items: []string{"indirect"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "both direct and indirect",
|
||||
items: []string{"direct", "indirect"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "both with other items",
|
||||
items: []string{"other", "direct", "more", "indirect", "stuff"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unrelated items",
|
||||
items: []string{"foo", "bar", "baz"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hasBothDirectAndIndirect(tt.items)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_filterCatalogersByName(t *testing.T) {
|
||||
catalogers := []capabilities.CatalogerEntry{
|
||||
{Name: "python-cataloger", Ecosystem: "python"},
|
||||
{Name: "java-cataloger", Ecosystem: "java"},
|
||||
{Name: "go-cataloger", Ecosystem: "golang"},
|
||||
{Name: "npm-cataloger", Ecosystem: "javascript"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
catalogers []capabilities.CatalogerEntry
|
||||
names []string
|
||||
want []capabilities.CatalogerEntry
|
||||
}{
|
||||
{
|
||||
name: "empty names returns empty",
|
||||
catalogers: catalogers,
|
||||
names: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single match",
|
||||
catalogers: catalogers,
|
||||
names: []string{"python-cataloger"},
|
||||
want: []capabilities.CatalogerEntry{
|
||||
{Name: "python-cataloger", Ecosystem: "python"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple matches",
|
||||
catalogers: catalogers,
|
||||
names: []string{"python-cataloger", "java-cataloger"},
|
||||
want: []capabilities.CatalogerEntry{
|
||||
{Name: "python-cataloger", Ecosystem: "python"},
|
||||
{Name: "java-cataloger", Ecosystem: "java"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no matches",
|
||||
catalogers: catalogers,
|
||||
names: []string{"nonexistent"},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "partial matches",
|
||||
catalogers: catalogers,
|
||||
names: []string{"python-cataloger", "nonexistent"},
|
||||
want: []capabilities.CatalogerEntry{
|
||||
{Name: "python-cataloger", Ecosystem: "python"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := filterCatalogersByName(tt.catalogers, tt.names)
|
||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
t.Errorf("filterCatalogersByName() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_formatCriteria(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
detectors []capabilities.Detector
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty detectors",
|
||||
detectors: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "single glob detector",
|
||||
detectors: []capabilities.Detector{
|
||||
{
|
||||
Method: capabilities.GlobDetection,
|
||||
Criteria: []string{"**/*.py"},
|
||||
},
|
||||
},
|
||||
want: "**/*.py",
|
||||
},
|
||||
{
|
||||
name: "multiple criteria single detector",
|
||||
detectors: []capabilities.Detector{
|
||||
{
|
||||
Method: capabilities.GlobDetection,
|
||||
Criteria: []string{"**/*.py", "**/*.pyc", "**/requirements.txt"},
|
||||
},
|
||||
},
|
||||
want: "**/*.py, **/*.pyc, **/requirements.txt",
|
||||
},
|
||||
{
|
||||
name: "path detection shows method",
|
||||
detectors: []capabilities.Detector{
|
||||
{
|
||||
Method: capabilities.PathDetection,
|
||||
Criteria: []string{"/usr/bin/python"},
|
||||
},
|
||||
},
|
||||
want: "/usr/bin/python (path)",
|
||||
},
|
||||
{
|
||||
name: "mimetype detection shows method",
|
||||
detectors: []capabilities.Detector{
|
||||
{
|
||||
Method: capabilities.MIMETypeDetection,
|
||||
Criteria: []string{"application/x-python"},
|
||||
},
|
||||
},
|
||||
want: "application/x-python (mimetype)",
|
||||
},
|
||||
{
|
||||
name: "multiple detectors combine criteria",
|
||||
detectors: []capabilities.Detector{
|
||||
{
|
||||
Method: capabilities.GlobDetection,
|
||||
Criteria: []string{"**/*.py"},
|
||||
},
|
||||
{
|
||||
Method: capabilities.GlobDetection,
|
||||
Criteria: []string{"**/*.pyc"},
|
||||
},
|
||||
},
|
||||
want: "**/*.py, **/*.pyc",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatCriteria(tt.detectors)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testDocument() *capabilities.Document {
|
||||
return &capabilities.Document{
|
||||
Configs: map[string]capabilities.CatalogerConfigEntry{
|
||||
"test-config": {
|
||||
Fields: []capabilities.CatalogerConfigFieldEntry{
|
||||
{Key: "field1", Description: "test field"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Test_catalogerInfoReport(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *catalogerInfoOptions
|
||||
doc *capabilities.Document
|
||||
catalogers []capabilities.CatalogerEntry
|
||||
wantErr require.ErrorAssertionFunc
|
||||
assertions func(t *testing.T, got string)
|
||||
}{
|
||||
{
|
||||
name: "empty catalogers table",
|
||||
opts: &catalogerInfoOptions{Output: "table"},
|
||||
doc: testDocument(),
|
||||
assertions: func(t *testing.T, got string) {
|
||||
assert.Contains(t, got, "No catalogers found")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty catalogers json",
|
||||
opts: &catalogerInfoOptions{Output: "json"},
|
||||
doc: testDocument(),
|
||||
assertions: func(t *testing.T, got string) {
|
||||
assert.Contains(t, got, `"catalogers":null`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single cataloger table output",
|
||||
opts: &catalogerInfoOptions{Output: "table"},
|
||||
doc: testDocument(),
|
||||
catalogers: []capabilities.CatalogerEntry{
|
||||
{
|
||||
Name: "test-cataloger",
|
||||
Ecosystem: "test",
|
||||
Type: "custom",
|
||||
Detectors: []capabilities.Detector{
|
||||
{Method: capabilities.GlobDetection, Criteria: []string{"**/*.test"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
assertions: func(t *testing.T, got string) {
|
||||
assert.Contains(t, got, "test-cataloger")
|
||||
assert.Contains(t, got, "ECOSYSTEM")
|
||||
assert.Contains(t, got, "CATALOGER")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single cataloger json output",
|
||||
opts: &catalogerInfoOptions{Output: "json"},
|
||||
doc: testDocument(),
|
||||
catalogers: []capabilities.CatalogerEntry{
|
||||
{
|
||||
Name: "test-cataloger",
|
||||
Ecosystem: "test",
|
||||
Type: "custom",
|
||||
Selectors: []string{"test", "custom"},
|
||||
},
|
||||
},
|
||||
assertions: func(t *testing.T, got string) {
|
||||
assert.Contains(t, got, `"name":"test-cataloger"`)
|
||||
assert.Contains(t, got, `"ecosystem":"test"`)
|
||||
assert.Contains(t, got, `"type":"custom"`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deprecated cataloger json output",
|
||||
opts: &catalogerInfoOptions{Output: "json"},
|
||||
doc: testDocument(),
|
||||
catalogers: []capabilities.CatalogerEntry{
|
||||
{
|
||||
Name: "old-cataloger",
|
||||
Ecosystem: "legacy",
|
||||
Type: "custom",
|
||||
Selectors: []string{"legacy", "deprecated"},
|
||||
},
|
||||
},
|
||||
assertions: func(t *testing.T, got string) {
|
||||
assert.Contains(t, got, `"deprecated":true`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid output format",
|
||||
opts: &catalogerInfoOptions{Output: "invalid"},
|
||||
doc: testDocument(),
|
||||
wantErr: require.Error,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.wantErr == nil {
|
||||
tt.wantErr = require.NoError
|
||||
}
|
||||
|
||||
got, err := catalogerInfoReport(tt.opts, tt.doc, tt.catalogers)
|
||||
tt.wantErr(t, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if tt.assertions != nil {
|
||||
tt.assertions(t, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_formatCriteria_wordWrapping(t *testing.T) {
|
||||
// test word wrapping behavior with long criteria lists
|
||||
longCriteria := make([]string, 20)
|
||||
for i := range longCriteria {
|
||||
longCriteria[i] = "**/file" + strings.Repeat("x", 10)
|
||||
}
|
||||
|
||||
detector := capabilities.Detector{
|
||||
Method: capabilities.GlobDetection,
|
||||
Criteria: longCriteria,
|
||||
}
|
||||
|
||||
got := formatCriteria([]capabilities.Detector{detector})
|
||||
|
||||
// should contain newlines for wrapping
|
||||
assert.Contains(t, got, "\n")
|
||||
// should contain all criteria (joined with ", " and possibly split across lines)
|
||||
for _, c := range longCriteria {
|
||||
assert.Contains(t, got, c)
|
||||
}
|
||||
}
|
||||
@@ -1,321 +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/capabilities"
|
||||
"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"`
|
||||
options.CatalogerSelection `yaml:",inline" json:",inline" mapstructure:",squash"`
|
||||
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.BoolVarP(&o.ShowHidden, "show-hidden", "s", "show catalogers that have been de-selected")
|
||||
}
|
||||
|
||||
func defaultCatalogerListOptions() *catalogerListOptions {
|
||||
return &catalogerListOptions{
|
||||
CatalogerSelection: options.CatalogerSelection{
|
||||
// this is different than the default behavior where a scan will automatically detect the default set
|
||||
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.FlattenAndSort(opts.DefaultCatalogers)
|
||||
selectCatalogers := options.FlattenAndSort(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 jsonFormat:
|
||||
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) {
|
||||
infos := capabilities.ExtractCatalogerInfo(tasks)
|
||||
|
||||
tagsByName := make(map[string][]string)
|
||||
var names []string
|
||||
|
||||
for _, info := range infos {
|
||||
tagsByName[info.Name] = info.Selectors
|
||||
names = append(names, info.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)
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/spf13/cobra"
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/fangs"
|
||||
"github.com/anchore/go-collections"
|
||||
"github.com/anchore/stereoscope"
|
||||
"github.com/anchore/stereoscope/pkg/image"
|
||||
"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/file"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/task"
|
||||
"github.com/anchore/syft/syft"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
"github.com/anchore/syft/syft/source/sourceproviders"
|
||||
)
|
||||
|
||||
const (
|
||||
scanExample = ` {{.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 cyclonedx-json show a CycloneDX JSON formatted SBOM
|
||||
{{.appName}} {{.command}} alpine:latest -o spdx show a SPDX 2.3 Tag-Value formatted SBOM
|
||||
{{.appName}} {{.command}} alpine:latest -o spdx@2.2 show a SPDX 2.2 Tag-Value formatted SBOM
|
||||
{{.appName}} {{.command}} alpine:latest -o spdx-json show a SPDX 2.3 JSON formatted SBOM
|
||||
{{.appName}} {{.command}} alpine:latest -o spdx-json@2.2 show a SPDX 2.2 JSON formatted SBOM
|
||||
{{.appName}} {{.command}} alpine:latest -vv show verbose debug information
|
||||
{{.appName}} {{.command}} alpine:latest -o template -t my_format.tmpl show a SBOM formatted according to given template file
|
||||
|
||||
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, SIF container, or generic filesystem directory
|
||||
`
|
||||
|
||||
schemeHelpHeader = "You can also explicitly specify the scheme to use:"
|
||||
imageSchemeHelp = ` {{.appName}} {{.command}} docker:yourrepo/yourimage:tag explicitly use the Docker daemon
|
||||
{{.appName}} {{.command}} podman:yourrepo/yourimage:tag explicitly use the Podman daemon
|
||||
{{.appName}} {{.command}} registry:yourrepo/yourimage:tag pull image directly from a registry (no container runtime required)
|
||||
{{.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}} singularity:path/to/yourimage.sif read directly from a Singularity Image Format (SIF) container on disk
|
||||
`
|
||||
nonImageSchemeHelp = ` {{.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)
|
||||
`
|
||||
modelSchemeHelp = ` {{.appName}} {{.command}} oci-model-registry:ai/llama3.2 scan an OCI model artifact from a registry (e.g. Docker Hub AI models)
|
||||
`
|
||||
scanSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp + modelSchemeHelp + nonImageSchemeHelp
|
||||
|
||||
scanHelp = scanExample + scanSchemeHelp
|
||||
)
|
||||
|
||||
type scanOptions 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"`
|
||||
Cache options.Cache `json:"-" yaml:"cache" mapstructure:"cache"`
|
||||
}
|
||||
|
||||
func defaultScanOptions() *scanOptions {
|
||||
return &scanOptions{
|
||||
Output: options.DefaultOutput(),
|
||||
UpdateCheck: options.DefaultUpdateCheck(),
|
||||
Catalog: options.DefaultCatalog(),
|
||||
Cache: options.DefaultCache(),
|
||||
}
|
||||
}
|
||||
|
||||
func Scan(app clio.Application) *cobra.Command {
|
||||
id := app.ID()
|
||||
|
||||
opts := defaultScanOptions()
|
||||
|
||||
return app.SetupCommand(&cobra.Command{
|
||||
Use: "scan [SOURCE]",
|
||||
Short: "Generate an SBOM",
|
||||
Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from container images and filesystems",
|
||||
Example: internal.Tprintf(scanHelp, map[string]interface{}{
|
||||
"appName": id.Name,
|
||||
"command": "scan",
|
||||
}),
|
||||
Args: validateScanArgs,
|
||||
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)
|
||||
}
|
||||
|
||||
func (o *scanOptions) PostLoad() error {
|
||||
return o.validateLegacyOptionsNotUsed()
|
||||
}
|
||||
|
||||
func (o *scanOptions) validateLegacyOptionsNotUsed() error {
|
||||
if len(fangs.Flatten(o.ConfigFile)) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// check for legacy config file shapes that are no longer valid
|
||||
type legacyConfig struct {
|
||||
BasePath *string `yaml:"base-path" json:"base-path" mapstructure:"base-path"`
|
||||
DefaultImagePullSource *string `yaml:"default-image-pull-source" json:"default-image-pull-source" mapstructure:"default-image-pull-source"`
|
||||
ExcludeBinaryOverlapByOwnership *bool `yaml:"exclude-binary-overlap-by-ownership" json:"exclude-binary-overlap-by-ownership" mapstructure:"exclude-binary-overlap-by-ownership"`
|
||||
File any `yaml:"file" json:"file" mapstructure:"file"`
|
||||
}
|
||||
|
||||
for _, f := range fangs.Flatten(o.ConfigFile) {
|
||||
by, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read config file during validations %q: %w", f, err)
|
||||
}
|
||||
|
||||
var legacy legacyConfig
|
||||
if err := yaml.Unmarshal(by, &legacy); err != nil {
|
||||
return fmt.Errorf("unable to parse config file during validations %q: %w", f, err)
|
||||
}
|
||||
|
||||
if legacy.DefaultImagePullSource != nil {
|
||||
return fmt.Errorf("the config file option 'default-image-pull-source' has been removed, please use 'source.image.default-pull-source' instead")
|
||||
}
|
||||
|
||||
if legacy.ExcludeBinaryOverlapByOwnership != nil {
|
||||
return fmt.Errorf("the config file option 'exclude-binary-overlap-by-ownership' has been removed, please use 'package.exclude-binary-overlap-by-ownership' instead")
|
||||
}
|
||||
|
||||
if legacy.BasePath != nil {
|
||||
return fmt.Errorf("the config file option 'base-path' has been removed, please use 'source.base-path' instead")
|
||||
}
|
||||
|
||||
if legacy.File != nil && reflect.TypeOf(legacy.File).Kind() == reflect.String {
|
||||
return fmt.Errorf("the config file option 'file' has been removed, please use 'outputs' instead")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateScanArgs(cmd *cobra.Command, args []string) error {
|
||||
return validateArgs(cmd, args, "an image/directory argument is required")
|
||||
}
|
||||
|
||||
func validateArgs(cmd *cobra.Command, args []string, err 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("%v", err)
|
||||
}
|
||||
|
||||
return cobra.MaximumNArgs(1)(cmd, args)
|
||||
}
|
||||
|
||||
func runScan(ctx context.Context, id clio.Identification, opts *scanOptions, userInput string) error {
|
||||
writer, err := opts.SBOMWriter()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sources := opts.From
|
||||
if len(sources) == 0 {
|
||||
// extract a scheme if it matches any provider tag; this is a holdover for compatibility, using the --from flag is recommended
|
||||
explicitSource, newUserInput := stereoscope.ExtractSchemeSource(userInput, allSourceProviderTags()...)
|
||||
if explicitSource != "" {
|
||||
sources = append(sources, explicitSource)
|
||||
userInput = newUserInput
|
||||
}
|
||||
}
|
||||
|
||||
src, err := getSource(ctx, &opts.Catalog, userInput, sources...)
|
||||
if err != nil {
|
||||
return 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.Catalog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s == nil {
|
||||
return fmt.Errorf("no SBOM produced for %q", userInput)
|
||||
}
|
||||
|
||||
if err := writer.Write(*s); err != nil {
|
||||
return fmt.Errorf("failed to write SBOM: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSource(ctx context.Context, opts *options.Catalog, userInput string, sources ...string) (source.Source, error) {
|
||||
cfg := syft.DefaultGetSourceConfig().
|
||||
WithRegistryOptions(opts.Registry.ToOptions()).
|
||||
WithAlias(source.Alias{
|
||||
Name: opts.Source.Name,
|
||||
Version: opts.Source.Version,
|
||||
Supplier: opts.Source.Supplier,
|
||||
}).
|
||||
WithExcludeConfig(source.ExcludeConfig{
|
||||
Paths: opts.Exclusions,
|
||||
}).
|
||||
WithBasePath(opts.Source.BasePath).
|
||||
WithSources(sources...).
|
||||
WithDefaultImagePullSource(opts.Source.Image.DefaultPullSource)
|
||||
|
||||
var err error
|
||||
var platform *image.Platform
|
||||
|
||||
if opts.Platform != "" {
|
||||
platform, err = image.NewPlatform(opts.Platform)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid platform: %w", err)
|
||||
}
|
||||
cfg = cfg.WithPlatform(platform)
|
||||
}
|
||||
|
||||
if opts.Source.File.Digests != nil {
|
||||
hashers, err := file.Hashers(opts.Source.File.Digests...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hash algorithm: %w", err)
|
||||
}
|
||||
cfg = cfg.WithDigestAlgorithms(hashers...)
|
||||
}
|
||||
|
||||
src, err := syft.GetSource(ctx, userInput, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not determine source: %w", err)
|
||||
}
|
||||
|
||||
return src, nil
|
||||
}
|
||||
|
||||
func generateSBOM(ctx context.Context, id clio.Identification, src source.Source, opts *options.Catalog) (*sbom.SBOM, error) {
|
||||
s, err := syft.CreateSBOM(ctx, src, opts.ToSBOMConfig(id))
|
||||
if err != nil {
|
||||
expErrs := filterExpressionErrors(err)
|
||||
notifyExpressionErrors(expErrs)
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func filterExpressionErrors(err error) []task.ErrInvalidExpression {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
expErrs := processErrors(err)
|
||||
|
||||
return expErrs
|
||||
}
|
||||
|
||||
// processErrors traverses error chains and multierror lists and returns all ErrInvalidExpression errors found
|
||||
func processErrors(err error) []task.ErrInvalidExpression {
|
||||
var result []task.ErrInvalidExpression
|
||||
|
||||
var processError func(...error)
|
||||
processError = func(errs ...error) {
|
||||
for _, e := range errs {
|
||||
// note: using errors.As will result in surprising behavior (since that will traverse the error chain,
|
||||
// potentially skipping over nodes in a list of errors)
|
||||
if cerr, ok := e.(task.ErrInvalidExpression); ok {
|
||||
result = append(result, cerr)
|
||||
continue
|
||||
}
|
||||
var multiErr *multierror.Error
|
||||
if errors.As(e, &multiErr) {
|
||||
processError(multiErr.Errors...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processError(err)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func notifyExpressionErrors(expErrs []task.ErrInvalidExpression) {
|
||||
helpText := expressionErrorsHelp(expErrs)
|
||||
if helpText == "" {
|
||||
return
|
||||
}
|
||||
|
||||
bus.Notify(helpText)
|
||||
}
|
||||
|
||||
func expressionErrorsHelp(expErrs []task.ErrInvalidExpression) string {
|
||||
// enrich all errors found with CLI hints
|
||||
if len(expErrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
sb := strings.Builder{}
|
||||
|
||||
sb.WriteString("Suggestions:\n\n")
|
||||
|
||||
found := false
|
||||
for i, expErr := range expErrs {
|
||||
help := expressionSuggetions(expErr)
|
||||
if help == "" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
sb.WriteString(help)
|
||||
if i != len(expErrs)-1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ""
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
const expressionHelpTemplate = " ❖ Given expression %q\n%s%s"
|
||||
|
||||
func expressionSuggetions(expErr task.ErrInvalidExpression) string {
|
||||
if expErr.Err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
hint := getHintPhrase(expErr)
|
||||
if hint == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(expressionHelpTemplate,
|
||||
getExpression(expErr),
|
||||
indentMsg(getExplanation(expErr)),
|
||||
indentMsg(hint),
|
||||
)
|
||||
}
|
||||
|
||||
func indentMsg(msg string) string {
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(msg, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = " " + line
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func getExpression(expErr task.ErrInvalidExpression) string {
|
||||
flag := "--select-catalogers"
|
||||
if expErr.Operation == task.SetOperation {
|
||||
flag = "--override-default-catalogers"
|
||||
}
|
||||
return fmt.Sprintf("%s %s", flag, expErr.Expression)
|
||||
}
|
||||
|
||||
func getExplanation(expErr task.ErrInvalidExpression) string {
|
||||
err := expErr.Err
|
||||
if errors.Is(err, task.ErrUnknownNameOrTag) {
|
||||
noun := ""
|
||||
switch expErr.Operation {
|
||||
case task.AddOperation:
|
||||
noun = "name"
|
||||
case task.SubSelectOperation:
|
||||
noun = "tag"
|
||||
default:
|
||||
noun = "name or tag"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("However, %q is not a recognized cataloger %s.", trimOperation(expErr.Expression), noun)
|
||||
}
|
||||
|
||||
if errors.Is(err, task.ErrNamesNotAllowed) {
|
||||
if expErr.Operation == task.SubSelectOperation {
|
||||
return "However, " + err.Error() + ".\nIt seems like you are intending to add a cataloger in addition to the default set."
|
||||
}
|
||||
return "However, " + err.Error() + "."
|
||||
}
|
||||
|
||||
if errors.Is(err, task.ErrTagsNotAllowed) {
|
||||
return "However, " + err.Error() + ".\nAdding groups of catalogers may result in surprising behavior (create inaccurate SBOMs)."
|
||||
}
|
||||
|
||||
if errors.Is(err, task.ErrAllNotAllowed) {
|
||||
return "However, you " + err.Error() + ".\nIt seems like you are intending to use all catalogers (which is not recommended)."
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "However, this is not valid: " + err.Error()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func getHintPhrase(expErr task.ErrInvalidExpression) string {
|
||||
if errors.Is(expErr.Err, task.ErrUnknownNameOrTag) {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch expErr.Operation {
|
||||
case task.AddOperation:
|
||||
if errors.Is(expErr.Err, task.ErrTagsNotAllowed) {
|
||||
return fmt.Sprintf("If you are certain this is what you want to do, use %q instead.", "--override-default-catalogers "+trimOperation(expErr.Expression))
|
||||
}
|
||||
|
||||
case task.SubSelectOperation:
|
||||
didYouMean := "... Did you mean %q instead?"
|
||||
if errors.Is(expErr.Err, task.ErrNamesNotAllowed) {
|
||||
return fmt.Sprintf(didYouMean, "--select-catalogers +"+expErr.Expression)
|
||||
}
|
||||
|
||||
if errors.Is(expErr.Err, task.ErrAllNotAllowed) {
|
||||
return fmt.Sprintf(didYouMean, "--override-default-catalogers "+expErr.Expression)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func trimOperation(x string) string {
|
||||
return strings.TrimLeft(x, "+-")
|
||||
}
|
||||
|
||||
func allSourceProviderTags() []string {
|
||||
return collections.TaggedValueSet[source.Provider]{}.Join(sourceproviders.All("", nil)...).Tags()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user