mirror of
https://github.com/anchore/syft.git
synced 2026-02-12 02:26:42 +01:00
feat: snap can be queried by revision and ``track/risk/branch`` (#4439)
--------- Signed-off-by: Yuntao Hu <victorhu493@gmail.com>
This commit is contained in:
parent
74c9380248
commit
c9760d2341
@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@ -31,15 +32,21 @@ type remoteSnap struct {
|
|||||||
URL string
|
URL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NotSpecifiedRevision int = 0
|
||||||
|
|
||||||
type snapIdentity struct {
|
type snapIdentity struct {
|
||||||
Name string
|
Name string
|
||||||
Channel string
|
Channel string
|
||||||
Architecture string
|
Architecture string
|
||||||
|
Revision int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s snapIdentity) String() string {
|
func (s snapIdentity) String() string {
|
||||||
parts := []string{s.Name}
|
parts := []string{s.Name}
|
||||||
|
// revision will supersede channel
|
||||||
|
if s.Revision != NotSpecifiedRevision {
|
||||||
|
parts = append(parts, fmt.Sprintf(":%d", s.Revision))
|
||||||
|
} else {
|
||||||
if s.Channel != "" {
|
if s.Channel != "" {
|
||||||
parts = append(parts, fmt.Sprintf("@%s", s.Channel))
|
parts = append(parts, fmt.Sprintf("@%s", s.Channel))
|
||||||
}
|
}
|
||||||
@ -47,6 +54,7 @@ func (s snapIdentity) String() string {
|
|||||||
if s.Architecture != "" {
|
if s.Architecture != "" {
|
||||||
parts = append(parts, fmt.Sprintf(" (%s)", s.Architecture))
|
parts = append(parts, fmt.Sprintf(" (%s)", s.Architecture))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return strings.Join(parts, "")
|
return strings.Join(parts, "")
|
||||||
}
|
}
|
||||||
@ -166,17 +174,21 @@ func getSnapFileInfo(ctx context.Context, fs afero.Fs, path string, hashes []cry
|
|||||||
// The request can be:
|
// The request can be:
|
||||||
// - A snap name (e.g., "etcd")
|
// - A snap name (e.g., "etcd")
|
||||||
// - A snap name with channel (e.g., "etcd@beta" or "etcd@2.3/stable")
|
// - A snap name with channel (e.g., "etcd@beta" or "etcd@2.3/stable")
|
||||||
|
// - A snap name with revision (e.g. etcd:249@stable)
|
||||||
func resolveRemoteSnap(request, architecture string) (*remoteSnap, error) {
|
func resolveRemoteSnap(request, architecture string) (*remoteSnap, error) {
|
||||||
if architecture == "" {
|
if architecture == "" {
|
||||||
architecture = defaultArchitecture
|
architecture = defaultArchitecture
|
||||||
}
|
}
|
||||||
|
|
||||||
snapName, channel := parseSnapRequest(request)
|
snapName, revision, channel, err := parseSnapRequest(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
id := snapIdentity{
|
id := snapIdentity{
|
||||||
Name: snapName,
|
Name: snapName,
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
Architecture: architecture,
|
Architecture: architecture,
|
||||||
|
Revision: revision,
|
||||||
}
|
}
|
||||||
|
|
||||||
client := newSnapcraftClient()
|
client := newSnapcraftClient()
|
||||||
@ -194,15 +206,26 @@ func resolveRemoteSnap(request, architecture string) (*remoteSnap, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSnapRequest parses a snap request into name and channel
|
// parseSnapRequest parses a snap request into name and revision/channel
|
||||||
// Examples:
|
// Examples:
|
||||||
// - "etcd" -> name="etcd", channel="stable" (default)
|
// - "etcd" -> name="etcd", channel="stable" (default)
|
||||||
// - "etcd@beta" -> name="etcd", channel="beta"
|
// - "etcd@beta" -> name="etcd", channel="beta"
|
||||||
// - "etcd@2.3/stable" -> name="etcd", channel="2.3/stable"
|
// - "etcd@2.3/stable" -> name="etcd", channel="2.3/stable"
|
||||||
func parseSnapRequest(request string) (name, channel string) {
|
// - "etcd:249@2.3/stable" -> name="etcd" revision=249 (channel not working because revision has been assigned)
|
||||||
|
func parseSnapRequest(request string) (name string, revision int, channel string, err error) {
|
||||||
parts := strings.SplitN(request, "@", 2)
|
parts := strings.SplitN(request, "@", 2)
|
||||||
name = parts[0]
|
name = parts[0]
|
||||||
|
|
||||||
|
divisions := strings.Split(parts[0], ":")
|
||||||
|
// handle revision first
|
||||||
|
if len(divisions) == 2 {
|
||||||
|
name = divisions[0]
|
||||||
|
revision, err = strconv.Atoi(divisions[1])
|
||||||
|
if err != nil {
|
||||||
|
return "", NotSpecifiedRevision, "", err
|
||||||
|
}
|
||||||
|
return name, revision, "", err
|
||||||
|
}
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
channel = parts[1]
|
channel = parts[1]
|
||||||
}
|
}
|
||||||
@ -210,8 +233,7 @@ func parseSnapRequest(request string) (name, channel string) {
|
|||||||
if channel == "" {
|
if channel == "" {
|
||||||
channel = defaultChannel
|
channel = defaultChannel
|
||||||
}
|
}
|
||||||
|
return name, NotSpecifiedRevision, channel, err
|
||||||
return name, channel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadSnap(getter intFile.Getter, info *remoteSnap, dest string) error {
|
func downloadSnap(getter intFile.Getter, info *remoteSnap, dest string) error {
|
||||||
|
|||||||
@ -511,75 +511,106 @@ func TestParseSnapRequest(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
request string
|
request string
|
||||||
expectedName string
|
expectedName string
|
||||||
|
expectedRevision int
|
||||||
expectedChannel string
|
expectedChannel string
|
||||||
|
expectedError require.ErrorAssertionFunc
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "snap name only - uses default channel",
|
name: "snap name only - uses default channel",
|
||||||
request: "etcd",
|
request: "etcd",
|
||||||
expectedName: "etcd",
|
expectedName: "etcd",
|
||||||
expectedChannel: "stable",
|
expectedChannel: "stable",
|
||||||
|
expectedError: require.NoError,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "snap with beta channel",
|
name: "snap with beta channel",
|
||||||
request: "etcd@beta",
|
request: "etcd@beta",
|
||||||
expectedName: "etcd",
|
expectedName: "etcd",
|
||||||
expectedChannel: "beta",
|
expectedChannel: "beta",
|
||||||
|
expectedError: require.NoError,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "snap with edge channel",
|
name: "snap with edge channel",
|
||||||
request: "etcd@edge",
|
request: "etcd@edge",
|
||||||
expectedName: "etcd",
|
expectedName: "etcd",
|
||||||
expectedChannel: "edge",
|
expectedChannel: "edge",
|
||||||
|
expectedError: require.NoError,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "snap with version track",
|
name: "snap with version track",
|
||||||
request: "etcd@2.3/stable",
|
request: "etcd@2.3/stable",
|
||||||
expectedName: "etcd",
|
expectedName: "etcd",
|
||||||
expectedChannel: "2.3/stable",
|
expectedChannel: "2.3/stable",
|
||||||
|
expectedError: require.NoError,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "snap with complex channel path",
|
name: "snap with complex channel path",
|
||||||
request: "mysql@8.0/candidate",
|
request: "mysql@8.0/candidate",
|
||||||
expectedName: "mysql",
|
expectedName: "mysql",
|
||||||
expectedChannel: "8.0/candidate",
|
expectedChannel: "8.0/candidate",
|
||||||
|
expectedError: require.NoError,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "snap with multiple @ symbols - only first is delimiter",
|
name: "snap with multiple @ symbols - only first is delimiter",
|
||||||
request: "app@beta@test",
|
request: "app@beta@test",
|
||||||
expectedName: "app",
|
expectedName: "app",
|
||||||
expectedChannel: "beta@test",
|
expectedChannel: "beta@test",
|
||||||
|
expectedError: require.NoError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "snap with revision",
|
||||||
|
request: "etcd:249",
|
||||||
|
expectedName: "etcd",
|
||||||
|
expectedRevision: 249,
|
||||||
|
expectedError: require.NoError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "snap with revision so the channel doesn't work",
|
||||||
|
request: "etcd:249@2.3/beta",
|
||||||
|
expectedName: "etcd",
|
||||||
|
expectedRevision: 249,
|
||||||
|
expectedError: require.NoError,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty snap name with channel",
|
name: "empty snap name with channel",
|
||||||
request: "@stable",
|
request: "@stable",
|
||||||
expectedName: "",
|
expectedName: "",
|
||||||
expectedChannel: "stable",
|
expectedChannel: "stable",
|
||||||
|
expectedError: require.NoError,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "snap name with empty channel - uses default",
|
name: "snap name with empty channel - uses default",
|
||||||
request: "etcd@",
|
request: "etcd@",
|
||||||
expectedName: "etcd",
|
expectedName: "etcd",
|
||||||
expectedChannel: "stable",
|
expectedChannel: "stable",
|
||||||
|
expectedError: require.NoError,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hyphenated snap name",
|
name: "hyphenated snap name",
|
||||||
request: "hello-world@stable",
|
request: "hello-world@stable",
|
||||||
expectedName: "hello-world",
|
expectedName: "hello-world",
|
||||||
expectedChannel: "stable",
|
expectedChannel: "stable",
|
||||||
|
expectedError: require.NoError,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "snap name with numbers",
|
name: "snap name with numbers",
|
||||||
request: "app123",
|
request: "app123",
|
||||||
expectedName: "app123",
|
expectedName: "app123",
|
||||||
expectedChannel: "stable",
|
expectedChannel: "stable",
|
||||||
|
expectedError: require.NoError,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
name, channel := parseSnapRequest(tt.request)
|
name, revision, channel, err := parseSnapRequest(tt.request)
|
||||||
assert.Equal(t, tt.expectedName, name)
|
assert.Equal(t, tt.expectedName, name)
|
||||||
|
if tt.expectedRevision != NotSpecifiedRevision {
|
||||||
|
assert.Equal(t, tt.expectedRevision, revision)
|
||||||
|
} else {
|
||||||
assert.Equal(t, tt.expectedChannel, channel)
|
assert.Equal(t, tt.expectedChannel, channel)
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/anchore/syft/internal/log"
|
"github.com/anchore/syft/internal/log"
|
||||||
)
|
)
|
||||||
@ -58,17 +61,133 @@ type snapFindResponse struct {
|
|||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SnapRisk string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RiskStable SnapRisk = "stable"
|
||||||
|
RiskCandidate SnapRisk = "candidate"
|
||||||
|
RiskBeta SnapRisk = "beta"
|
||||||
|
RiskEdge SnapRisk = "edge"
|
||||||
|
RiskUnknown SnapRisk = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isValidSnapRisk(r SnapRisk) bool {
|
||||||
|
switch r {
|
||||||
|
case RiskStable, RiskCandidate, RiskBeta, RiskEdge:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringToSnapRisk(s string) SnapRisk {
|
||||||
|
r := SnapRisk(s)
|
||||||
|
if !isValidSnapRisk(r) {
|
||||||
|
return RiskUnknown
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRevisionFromURL(cm snapChannelMapEntry) (rev int, err error) {
|
||||||
|
re := regexp.MustCompile(`(\d+)\.snap$`)
|
||||||
|
match := re.FindStringSubmatch(cm.Download.URL)
|
||||||
|
if len(match) < 2 {
|
||||||
|
err = fmt.Errorf("could not determine revision from %s", cm.Download.URL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rev, err = strconv.Atoi(match[1])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEligibleChannel determines whether a candidate channel satisfies a requested
|
||||||
|
// channel. Both channels are parsed into {track, risk} pairs.
|
||||||
|
//
|
||||||
|
// Matching rules:
|
||||||
|
// - If the request includes a track, both track and risk must match exactly.
|
||||||
|
// - If the request omits the track (e.g., "stable"), any candidate track is
|
||||||
|
// accepted as long as the risk matches.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
//
|
||||||
|
// candidate="3.2/stable", request="stable" -> true
|
||||||
|
// candidate="3.2/stable", request="3.2/stable" -> true
|
||||||
|
// candidate="3.2/stable", request="3.2/beta" -> false
|
||||||
|
// candidate="3.2/beta", request="stable" -> false
|
||||||
|
// candidate="3.2/alpha", request="alpha" -> false(alpha is an invalid risk level)
|
||||||
|
// candidate="3.2/stable/fix-for-bug123", request="stable" -> true
|
||||||
|
// candidate="3.2/stable/fix-for-bug123", request="3.2/stable" -> true
|
||||||
|
func isEligibleChannel(candidate, request string) (bool, error) {
|
||||||
|
cTrack, cRisk, cBranch := splitChannel(candidate)
|
||||||
|
rTrack, rRisk, rBranch := splitChannel(request)
|
||||||
|
if rTrack == "" && rRisk == "" && rBranch == "" {
|
||||||
|
return false, fmt.Errorf("there is no such risk in the channel(only stable/candidate/beta/edge are valid)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rTrack != "" {
|
||||||
|
return cTrack == rTrack && cRisk == rRisk && (cBranch == rBranch || rBranch == ""), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return cRisk == rRisk && (cBranch == rBranch || rBranch == ""), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitChannel(ch string) (track string, risk string, branch string) {
|
||||||
|
parts := strings.SplitN(ch, "/", 3)
|
||||||
|
if stringToSnapRisk(parts[0]) != RiskUnknown {
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return "", parts[0], "" // no track
|
||||||
|
} else if len(parts) == 2 {
|
||||||
|
return "", parts[0], parts[1]
|
||||||
|
}
|
||||||
|
} else if len(parts) >= 2 && stringToSnapRisk(parts[1]) != RiskUnknown {
|
||||||
|
if len(parts) == 3 {
|
||||||
|
return parts[0], parts[1], parts[2]
|
||||||
|
} else if len(parts) == 2 {
|
||||||
|
return parts[0], parts[1], ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchSnapDownloadURL(cm snapChannelMapEntry, id snapIdentity) (string, error) {
|
||||||
|
// revision will supersede channel
|
||||||
|
if id.Revision != NotSpecifiedRevision {
|
||||||
|
rev, err2 := getRevisionFromURL(cm)
|
||||||
|
if err2 == nil && rev == id.Revision {
|
||||||
|
return cm.Download.URL, nil
|
||||||
|
}
|
||||||
|
} else if cm.Channel.Architecture == id.Architecture {
|
||||||
|
matched, err2 := isEligibleChannel(cm.Channel.Name, id.Channel)
|
||||||
|
if err2 != nil {
|
||||||
|
return "", err2
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
return cm.Download.URL, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetSnapDownloadURL retrieves the download URL for a snap package
|
// GetSnapDownloadURL retrieves the download URL for a snap package
|
||||||
func (c *snapcraftClient) GetSnapDownloadURL(id snapIdentity) (string, error) {
|
func (c *snapcraftClient) GetSnapDownloadURL(id snapIdentity) (string, error) {
|
||||||
apiURL := c.InfoAPIURL + id.Name
|
apiURL := c.InfoAPIURL + id.Name
|
||||||
|
|
||||||
|
if id.Revision == NotSpecifiedRevision {
|
||||||
log.WithFields("name", id.Name, "channel", id.Channel, "architecture", id.Architecture).Trace("requesting snap info")
|
log.WithFields("name", id.Name, "channel", id.Channel, "architecture", id.Architecture).Trace("requesting snap info")
|
||||||
|
} else {
|
||||||
|
log.WithFields("name", id.Name, "revision", id.Revision, "architecture", id.Architecture).Trace("requesting snap info")
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create HTTP request: %w", err)
|
return "", fmt.Errorf("failed to create HTTP request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if id.Revision != NotSpecifiedRevision {
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Add("revision", fmt.Sprintf("%d", id.Revision))
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
req.Header.Set("Snap-Device-Series", defaultSeries)
|
req.Header.Set("Snap-Device-Series", defaultSeries)
|
||||||
|
|
||||||
resp, err := c.HTTPClient.Do(req)
|
resp, err := c.HTTPClient.Do(req)
|
||||||
@ -107,9 +226,11 @@ func (c *snapcraftClient) GetSnapDownloadURL(id snapIdentity) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, cm := range info.ChannelMap {
|
for _, cm := range info.ChannelMap {
|
||||||
if cm.Channel.Architecture == id.Architecture && cm.Channel.Name == id.Channel {
|
url, err2 := matchSnapDownloadURL(cm, id)
|
||||||
return cm.Download.URL, nil
|
if url == "" && err2 == nil {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
return url, err2
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("no matching snap found for %s", id.String())
|
return "", fmt.Errorf("no matching snap found for %s", id.String())
|
||||||
|
|||||||
@ -161,6 +161,126 @@ func TestSnapcraftClient_GetSnapDownloadURL(t *testing.T) {
|
|||||||
expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap",
|
expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap",
|
||||||
expectError: require.NoError,
|
expectError: require.NoError,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "successful download URL retrieval (w/ track)",
|
||||||
|
snapID: snapIdentity{
|
||||||
|
Name: "etcd",
|
||||||
|
Channel: "stable",
|
||||||
|
Architecture: "amd64",
|
||||||
|
},
|
||||||
|
infoStatusCode: http.StatusOK,
|
||||||
|
infoResponse: snapcraftInfo{
|
||||||
|
ChannelMap: []snapChannelMapEntry{
|
||||||
|
{
|
||||||
|
Channel: snapChannel{
|
||||||
|
Architecture: "amd64",
|
||||||
|
Name: "3.2/stable",
|
||||||
|
},
|
||||||
|
Download: snapDownload{
|
||||||
|
URL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap",
|
||||||
|
expectError: require.NoError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "successful download URL retrieval (w/ track&branch)",
|
||||||
|
snapID: snapIdentity{
|
||||||
|
Name: "etcd",
|
||||||
|
Channel: "stable",
|
||||||
|
Architecture: "amd64",
|
||||||
|
},
|
||||||
|
infoStatusCode: http.StatusOK,
|
||||||
|
infoResponse: snapcraftInfo{
|
||||||
|
ChannelMap: []snapChannelMapEntry{
|
||||||
|
{
|
||||||
|
Channel: snapChannel{
|
||||||
|
Architecture: "amd64",
|
||||||
|
Name: "3.2/stable/fix-for-bug123",
|
||||||
|
},
|
||||||
|
Download: snapDownload{
|
||||||
|
URL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap",
|
||||||
|
expectError: require.NoError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "branch unmatched",
|
||||||
|
snapID: snapIdentity{
|
||||||
|
Name: "etcd",
|
||||||
|
Channel: "stable/fix-for-bug124",
|
||||||
|
Architecture: "amd64",
|
||||||
|
},
|
||||||
|
infoStatusCode: http.StatusOK,
|
||||||
|
infoResponse: snapcraftInfo{
|
||||||
|
ChannelMap: []snapChannelMapEntry{
|
||||||
|
{
|
||||||
|
Channel: snapChannel{
|
||||||
|
Architecture: "amd64",
|
||||||
|
Name: "3.2/stable/fix-for-bug123",
|
||||||
|
},
|
||||||
|
Download: snapDownload{
|
||||||
|
URL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: require.Error,
|
||||||
|
errorContains: "no matching snap found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "risk unmatched",
|
||||||
|
snapID: snapIdentity{
|
||||||
|
Name: "etcd",
|
||||||
|
Channel: "stable",
|
||||||
|
Architecture: "amd64",
|
||||||
|
},
|
||||||
|
infoStatusCode: http.StatusOK,
|
||||||
|
infoResponse: snapcraftInfo{
|
||||||
|
ChannelMap: []snapChannelMapEntry{
|
||||||
|
{
|
||||||
|
Channel: snapChannel{
|
||||||
|
Architecture: "amd64",
|
||||||
|
Name: "latest/beta",
|
||||||
|
},
|
||||||
|
Download: snapDownload{
|
||||||
|
URL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: require.Error,
|
||||||
|
errorContains: "no matching snap found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "illegal risk",
|
||||||
|
snapID: snapIdentity{
|
||||||
|
Name: "etcd",
|
||||||
|
Channel: "foobar",
|
||||||
|
Architecture: "amd64",
|
||||||
|
},
|
||||||
|
infoStatusCode: http.StatusOK,
|
||||||
|
infoResponse: snapcraftInfo{
|
||||||
|
ChannelMap: []snapChannelMapEntry{
|
||||||
|
{
|
||||||
|
Channel: snapChannel{
|
||||||
|
Architecture: "amd64",
|
||||||
|
Name: "latest/beta",
|
||||||
|
},
|
||||||
|
Download: snapDownload{
|
||||||
|
URL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: require.Error,
|
||||||
|
errorContains: "there is no such risk",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "region-locked snap - exists but unavailable",
|
name: "region-locked snap - exists but unavailable",
|
||||||
snapID: snapIdentity{
|
snapID: snapIdentity{
|
||||||
@ -351,6 +471,214 @@ func TestSnapcraftClient_GetSnapDownloadURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSnapcraftClient_GetSnapDownloadURL_WithVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
snapID snapIdentity
|
||||||
|
infoResponse snapcraftInfo
|
||||||
|
infoStatusCode int
|
||||||
|
findResponse *snapFindResponse
|
||||||
|
findStatusCode int
|
||||||
|
expectedURL string
|
||||||
|
expectError require.ErrorAssertionFunc
|
||||||
|
errorContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful download URL retrieval",
|
||||||
|
snapID: snapIdentity{
|
||||||
|
Name: "etcd",
|
||||||
|
Channel: "stable",
|
||||||
|
Architecture: "amd64",
|
||||||
|
Revision: 249,
|
||||||
|
},
|
||||||
|
infoStatusCode: http.StatusOK,
|
||||||
|
infoResponse: snapcraftInfo{
|
||||||
|
ChannelMap: []snapChannelMapEntry{
|
||||||
|
{
|
||||||
|
Channel: snapChannel{
|
||||||
|
Architecture: "amd64",
|
||||||
|
Name: "stable",
|
||||||
|
},
|
||||||
|
Download: snapDownload{
|
||||||
|
URL: "https://api.snapcraft.io/api/v1/snaps/download/TKebVGcPeDKoOqAmNmczU2oWLtsojKD5_249.snap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/TKebVGcPeDKoOqAmNmczU2oWLtsojKD5_249.snap",
|
||||||
|
expectError: require.NoError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.expectError == nil {
|
||||||
|
tt.expectError = require.NoError
|
||||||
|
}
|
||||||
|
|
||||||
|
infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, defaultSeries, r.Header.Get("Snap-Device-Series"))
|
||||||
|
|
||||||
|
expectedPath := "/" + tt.snapID.Name
|
||||||
|
assert.Equal(t, expectedPath, r.URL.Path)
|
||||||
|
|
||||||
|
w.WriteHeader(tt.infoStatusCode)
|
||||||
|
|
||||||
|
if tt.infoStatusCode == http.StatusOK {
|
||||||
|
responseBytes, err := json.Marshal(tt.infoResponse)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Write(responseBytes)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer infoServer.Close()
|
||||||
|
|
||||||
|
var findServer *httptest.Server
|
||||||
|
if tt.findResponse != nil || tt.findStatusCode != 0 {
|
||||||
|
findServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, defaultSeries, r.Header.Get("Snap-Device-Series"))
|
||||||
|
assert.Equal(t, tt.snapID.Name, r.URL.Query().Get("name-startswith"))
|
||||||
|
|
||||||
|
statusCode := tt.findStatusCode
|
||||||
|
if statusCode == 0 {
|
||||||
|
statusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
|
||||||
|
if tt.findResponse != nil && statusCode == http.StatusOK {
|
||||||
|
responseBytes, err := json.Marshal(tt.findResponse)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Write(responseBytes)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer findServer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &snapcraftClient{
|
||||||
|
InfoAPIURL: infoServer.URL + "/",
|
||||||
|
HTTPClient: &http.Client{},
|
||||||
|
}
|
||||||
|
if findServer != nil {
|
||||||
|
client.FindAPIURL = findServer.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := client.GetSnapDownloadURL(tt.snapID)
|
||||||
|
tt.expectError(t, err)
|
||||||
|
if err != nil {
|
||||||
|
if tt.errorContains != "" {
|
||||||
|
assert.Contains(t, err.Error(), tt.errorContains)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.expectedURL, url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapcraftClient_GetSnapDownloadURL_DoesntExist(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
snapID snapIdentity
|
||||||
|
infoResponse snapcraftInfo
|
||||||
|
infoStatusCode int
|
||||||
|
findResponse *snapFindResponse
|
||||||
|
findStatusCode int
|
||||||
|
expectedURL string
|
||||||
|
expectError require.ErrorAssertionFunc
|
||||||
|
errorContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "non-existent snap with revision",
|
||||||
|
snapID: snapIdentity{
|
||||||
|
Name: "etcd",
|
||||||
|
Channel: "stable",
|
||||||
|
Architecture: "amd64",
|
||||||
|
Revision: 248,
|
||||||
|
},
|
||||||
|
infoStatusCode: http.StatusOK,
|
||||||
|
infoResponse: snapcraftInfo{
|
||||||
|
ChannelMap: []snapChannelMapEntry{
|
||||||
|
{
|
||||||
|
Channel: snapChannel{
|
||||||
|
Architecture: "amd64",
|
||||||
|
Name: "stable",
|
||||||
|
},
|
||||||
|
Download: snapDownload{
|
||||||
|
URL: "https://api.snapcraft.io/api/v1/snaps/download/TKebVGcPeDKoOqAmNmczU2oWLtsojKD5_249.snap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/TKebVGcPeDKoOqAmNmczU2oWLtsojKD5_249.snap",
|
||||||
|
expectError: func(t require.TestingT, err error, msgAndArgs ...interface{}) {
|
||||||
|
require.EqualError(t, err, "no matching snap found for etcd:248")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.expectError == nil {
|
||||||
|
tt.expectError = require.NoError
|
||||||
|
}
|
||||||
|
|
||||||
|
infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, defaultSeries, r.Header.Get("Snap-Device-Series"))
|
||||||
|
|
||||||
|
expectedPath := "/" + tt.snapID.Name
|
||||||
|
assert.Equal(t, expectedPath, r.URL.Path)
|
||||||
|
|
||||||
|
w.WriteHeader(tt.infoStatusCode)
|
||||||
|
|
||||||
|
if tt.infoStatusCode == http.StatusOK {
|
||||||
|
responseBytes, err := json.Marshal(tt.infoResponse)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Write(responseBytes)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer infoServer.Close()
|
||||||
|
|
||||||
|
var findServer *httptest.Server
|
||||||
|
if tt.findResponse != nil || tt.findStatusCode != 0 {
|
||||||
|
findServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, defaultSeries, r.Header.Get("Snap-Device-Series"))
|
||||||
|
assert.Equal(t, tt.snapID.Name, r.URL.Query().Get("name-startswith"))
|
||||||
|
|
||||||
|
statusCode := tt.findStatusCode
|
||||||
|
if statusCode == 0 {
|
||||||
|
statusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
|
||||||
|
if tt.findResponse != nil && statusCode == http.StatusOK {
|
||||||
|
responseBytes, err := json.Marshal(tt.findResponse)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Write(responseBytes)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer findServer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &snapcraftClient{
|
||||||
|
InfoAPIURL: infoServer.URL + "/",
|
||||||
|
HTTPClient: &http.Client{},
|
||||||
|
}
|
||||||
|
if findServer != nil {
|
||||||
|
client.FindAPIURL = findServer.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := client.GetSnapDownloadURL(tt.snapID)
|
||||||
|
tt.expectError(t, err)
|
||||||
|
if err != nil {
|
||||||
|
if tt.errorContains != "" {
|
||||||
|
assert.Contains(t, err.Error(), tt.errorContains)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.expectedURL, url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSnapcraftClient_GetSnapDownloadURL_InvalidJSON(t *testing.T) {
|
func TestSnapcraftClient_GetSnapDownloadURL_InvalidJSON(t *testing.T) {
|
||||||
infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user