Initial commit
This commit is contained in:
191
vendor/github.com/hyperhq/hypercli/reference/reference.go
generated
vendored
Normal file
191
vendor/github.com/hyperhq/hypercli/reference/reference.go
generated
vendored
Normal file
@@ -0,0 +1,191 @@
|
||||
package reference
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/distribution/digest"
|
||||
distreference "github.com/docker/distribution/reference"
|
||||
"github.com/hyperhq/hypercli/image/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultTag defines the default tag used when performing images related actions and no tag or digest is specified
|
||||
DefaultTag = "latest"
|
||||
// DefaultHostname is the default built-in hostname
|
||||
DefaultHostname = "docker.io"
|
||||
// LegacyDefaultHostname is automatically converted to DefaultHostname
|
||||
LegacyDefaultHostname = "index.docker.io"
|
||||
// DefaultRepoPrefix is the prefix used for default repositories in default host
|
||||
DefaultRepoPrefix = "library/"
|
||||
)
|
||||
|
||||
// Named is an object with a full name
|
||||
type Named interface {
|
||||
// Name returns normalized repository name, like "ubuntu".
|
||||
Name() string
|
||||
// String returns full reference, like "ubuntu@sha256:abcdef..."
|
||||
String() string
|
||||
// FullName returns full repository name with hostname, like "docker.io/library/ubuntu"
|
||||
FullName() string
|
||||
// Hostname returns hostname for the reference, like "docker.io"
|
||||
Hostname() string
|
||||
// RemoteName returns the repository component of the full name, like "library/ubuntu"
|
||||
RemoteName() string
|
||||
}
|
||||
|
||||
// NamedTagged is an object including a name and tag.
|
||||
type NamedTagged interface {
|
||||
Named
|
||||
Tag() string
|
||||
}
|
||||
|
||||
// Canonical reference is an object with a fully unique
|
||||
// name including a name with hostname and digest
|
||||
type Canonical interface {
|
||||
Named
|
||||
Digest() digest.Digest
|
||||
}
|
||||
|
||||
// ParseNamed parses s and returns a syntactically valid reference implementing
|
||||
// the Named interface. The reference must have a name, otherwise an error is
|
||||
// returned.
|
||||
// If an error was encountered it is returned, along with a nil Reference.
|
||||
func ParseNamed(s string) (Named, error) {
|
||||
named, err := distreference.ParseNamed(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error parsing reference: %q is not a valid repository/tag", s)
|
||||
}
|
||||
r, err := WithName(named.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if canonical, isCanonical := named.(distreference.Canonical); isCanonical {
|
||||
return WithDigest(r, canonical.Digest())
|
||||
}
|
||||
if tagged, isTagged := named.(distreference.NamedTagged); isTagged {
|
||||
return WithTag(r, tagged.Tag())
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// WithName returns a named object representing the given string. If the input
|
||||
// is invalid ErrReferenceInvalidFormat will be returned.
|
||||
func WithName(name string) (Named, error) {
|
||||
name = normalize(name)
|
||||
if err := validateName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := distreference.WithName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &namedRef{r}, nil
|
||||
}
|
||||
|
||||
// WithTag combines the name from "name" and the tag from "tag" to form a
|
||||
// reference incorporating both the name and the tag.
|
||||
func WithTag(name Named, tag string) (NamedTagged, error) {
|
||||
r, err := distreference.WithTag(name, tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &taggedRef{namedRef{r}}, nil
|
||||
}
|
||||
|
||||
// WithDigest combines the name from "name" and the digest from "digest" to form
|
||||
// a reference incorporating both the name and the digest.
|
||||
func WithDigest(name Named, digest digest.Digest) (Canonical, error) {
|
||||
r, err := distreference.WithDigest(name, digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &canonicalRef{namedRef{r}}, nil
|
||||
}
|
||||
|
||||
type namedRef struct {
|
||||
distreference.Named
|
||||
}
|
||||
type taggedRef struct {
|
||||
namedRef
|
||||
}
|
||||
type canonicalRef struct {
|
||||
namedRef
|
||||
}
|
||||
|
||||
func (r *namedRef) FullName() string {
|
||||
hostname, remoteName := splitHostname(r.Name())
|
||||
return hostname + "/" + remoteName
|
||||
}
|
||||
func (r *namedRef) Hostname() string {
|
||||
hostname, _ := splitHostname(r.Name())
|
||||
return hostname
|
||||
}
|
||||
func (r *namedRef) RemoteName() string {
|
||||
_, remoteName := splitHostname(r.Name())
|
||||
return remoteName
|
||||
}
|
||||
func (r *taggedRef) Tag() string {
|
||||
return r.namedRef.Named.(distreference.NamedTagged).Tag()
|
||||
}
|
||||
func (r *canonicalRef) Digest() digest.Digest {
|
||||
return r.namedRef.Named.(distreference.Canonical).Digest()
|
||||
}
|
||||
|
||||
// WithDefaultTag adds a default tag to a reference if it only has a repo name.
|
||||
func WithDefaultTag(ref Named) Named {
|
||||
if IsNameOnly(ref) {
|
||||
ref, _ = WithTag(ref, DefaultTag)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// IsNameOnly returns true if reference only contains a repo name.
|
||||
func IsNameOnly(ref Named) bool {
|
||||
if _, ok := ref.(NamedTagged); ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := ref.(Canonical); ok {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// splitHostname splits a repository name to hostname and remotename string.
|
||||
// If no valid hostname is found, the default hostname is used. Repository name
|
||||
// needs to be already validated before.
|
||||
func splitHostname(name string) (hostname, remoteName string) {
|
||||
i := strings.IndexRune(name, '/')
|
||||
if i == -1 || (!strings.ContainsAny(name[:i], ".:") && name[:i] != "localhost") {
|
||||
hostname, remoteName = DefaultHostname, name
|
||||
} else {
|
||||
hostname, remoteName = name[:i], name[i+1:]
|
||||
}
|
||||
if hostname == LegacyDefaultHostname {
|
||||
hostname = DefaultHostname
|
||||
}
|
||||
if hostname == DefaultHostname && !strings.ContainsRune(remoteName, '/') {
|
||||
remoteName = DefaultRepoPrefix + remoteName
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// normalize returns a repository name in its normalized form, meaning it
|
||||
// will not contain default hostname nor library/ prefix for official images.
|
||||
func normalize(name string) string {
|
||||
host, remoteName := splitHostname(name)
|
||||
if host == DefaultHostname {
|
||||
if strings.HasPrefix(remoteName, DefaultRepoPrefix) {
|
||||
return strings.TrimPrefix(remoteName, DefaultRepoPrefix)
|
||||
}
|
||||
return remoteName
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func validateName(name string) error {
|
||||
if err := v1.ValidateID(name); err == nil {
|
||||
return fmt.Errorf("Invalid repository name (%s), cannot specify 64-byte hexadecimal strings", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
275
vendor/github.com/hyperhq/hypercli/reference/reference_test.go
generated
vendored
Normal file
275
vendor/github.com/hyperhq/hypercli/reference/reference_test.go
generated
vendored
Normal file
@@ -0,0 +1,275 @@
|
||||
package reference
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/distribution/digest"
|
||||
)
|
||||
|
||||
func TestValidateReferenceName(t *testing.T) {
|
||||
validRepoNames := []string{
|
||||
"docker/docker",
|
||||
"library/debian",
|
||||
"debian",
|
||||
"docker.io/docker/docker",
|
||||
"docker.io/library/debian",
|
||||
"docker.io/debian",
|
||||
"index.docker.io/docker/docker",
|
||||
"index.docker.io/library/debian",
|
||||
"index.docker.io/debian",
|
||||
"127.0.0.1:5000/docker/docker",
|
||||
"127.0.0.1:5000/library/debian",
|
||||
"127.0.0.1:5000/debian",
|
||||
"thisisthesongthatneverendsitgoesonandonandonthisisthesongthatnev",
|
||||
}
|
||||
invalidRepoNames := []string{
|
||||
"https://github.com/hyperhq/hypercli",
|
||||
"docker/Docker",
|
||||
"-docker",
|
||||
"-docker/docker",
|
||||
"-docker.io/docker/docker",
|
||||
"docker///docker",
|
||||
"docker.io/docker/Docker",
|
||||
"docker.io/docker///docker",
|
||||
"1a3f5e7d9c1b3a5f7e9d1c3b5a7f9e1d3c5b7a9f1e3d5d7c9b1a3f5e7d9c1b3a",
|
||||
"docker.io/1a3f5e7d9c1b3a5f7e9d1c3b5a7f9e1d3c5b7a9f1e3d5d7c9b1a3f5e7d9c1b3a",
|
||||
}
|
||||
|
||||
for _, name := range invalidRepoNames {
|
||||
_, err := ParseNamed(name)
|
||||
if err == nil {
|
||||
t.Fatalf("Expected invalid repo name for %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range validRepoNames {
|
||||
_, err := ParseNamed(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Error parsing repo name %s, got: %q", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRemoteName(t *testing.T) {
|
||||
validRepositoryNames := []string{
|
||||
// Sanity check.
|
||||
"docker/docker",
|
||||
|
||||
// Allow 64-character non-hexadecimal names (hexadecimal names are forbidden).
|
||||
"thisisthesongthatneverendsitgoesonandonandonthisisthesongthatnev",
|
||||
|
||||
// Allow embedded hyphens.
|
||||
"docker-rules/docker",
|
||||
|
||||
// Allow multiple hyphens as well.
|
||||
"docker---rules/docker",
|
||||
|
||||
//Username doc and image name docker being tested.
|
||||
"doc/docker",
|
||||
|
||||
// single character names are now allowed.
|
||||
"d/docker",
|
||||
"jess/t",
|
||||
|
||||
// Consecutive underscores.
|
||||
"dock__er/docker",
|
||||
}
|
||||
for _, repositoryName := range validRepositoryNames {
|
||||
_, err := ParseNamed(repositoryName)
|
||||
if err != nil {
|
||||
t.Errorf("Repository name should be valid: %v. Error: %v", repositoryName, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalidRepositoryNames := []string{
|
||||
// Disallow capital letters.
|
||||
"docker/Docker",
|
||||
|
||||
// Only allow one slash.
|
||||
"docker///docker",
|
||||
|
||||
// Disallow 64-character hexadecimal.
|
||||
"1a3f5e7d9c1b3a5f7e9d1c3b5a7f9e1d3c5b7a9f1e3d5d7c9b1a3f5e7d9c1b3a",
|
||||
|
||||
// Disallow leading and trailing hyphens in namespace.
|
||||
"-docker/docker",
|
||||
"docker-/docker",
|
||||
"-docker-/docker",
|
||||
|
||||
// Don't allow underscores everywhere (as opposed to hyphens).
|
||||
"____/____",
|
||||
|
||||
"_docker/_docker",
|
||||
|
||||
// Disallow consecutive periods.
|
||||
"dock..er/docker",
|
||||
"dock_.er/docker",
|
||||
"dock-.er/docker",
|
||||
|
||||
// No repository.
|
||||
"docker/",
|
||||
|
||||
//namespace too long
|
||||
"this_is_not_a_valid_namespace_because_its_lenth_is_greater_than_255_this_is_not_a_valid_namespace_because_its_lenth_is_greater_than_255_this_is_not_a_valid_namespace_because_its_lenth_is_greater_than_255_this_is_not_a_valid_namespace_because_its_lenth_is_greater_than_255/docker",
|
||||
}
|
||||
for _, repositoryName := range invalidRepositoryNames {
|
||||
if _, err := ParseNamed(repositoryName); err == nil {
|
||||
t.Errorf("Repository name should be invalid: %v", repositoryName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepositoryInfo(t *testing.T) {
|
||||
type tcase struct {
|
||||
RemoteName, NormalizedName, FullName, AmbiguousName, Hostname string
|
||||
}
|
||||
|
||||
tcases := []tcase{
|
||||
{
|
||||
RemoteName: "fooo/bar",
|
||||
NormalizedName: "fooo/bar",
|
||||
FullName: "docker.io/fooo/bar",
|
||||
AmbiguousName: "index.docker.io/fooo/bar",
|
||||
Hostname: "docker.io",
|
||||
},
|
||||
{
|
||||
RemoteName: "library/ubuntu",
|
||||
NormalizedName: "ubuntu",
|
||||
FullName: "docker.io/library/ubuntu",
|
||||
AmbiguousName: "library/ubuntu",
|
||||
Hostname: "docker.io",
|
||||
},
|
||||
{
|
||||
RemoteName: "nonlibrary/ubuntu",
|
||||
NormalizedName: "nonlibrary/ubuntu",
|
||||
FullName: "docker.io/nonlibrary/ubuntu",
|
||||
AmbiguousName: "",
|
||||
Hostname: "docker.io",
|
||||
},
|
||||
{
|
||||
RemoteName: "other/library",
|
||||
NormalizedName: "other/library",
|
||||
FullName: "docker.io/other/library",
|
||||
AmbiguousName: "",
|
||||
Hostname: "docker.io",
|
||||
},
|
||||
{
|
||||
RemoteName: "private/moonbase",
|
||||
NormalizedName: "127.0.0.1:8000/private/moonbase",
|
||||
FullName: "127.0.0.1:8000/private/moonbase",
|
||||
AmbiguousName: "",
|
||||
Hostname: "127.0.0.1:8000",
|
||||
},
|
||||
{
|
||||
RemoteName: "privatebase",
|
||||
NormalizedName: "127.0.0.1:8000/privatebase",
|
||||
FullName: "127.0.0.1:8000/privatebase",
|
||||
AmbiguousName: "",
|
||||
Hostname: "127.0.0.1:8000",
|
||||
},
|
||||
{
|
||||
RemoteName: "private/moonbase",
|
||||
NormalizedName: "example.com/private/moonbase",
|
||||
FullName: "example.com/private/moonbase",
|
||||
AmbiguousName: "",
|
||||
Hostname: "example.com",
|
||||
},
|
||||
{
|
||||
RemoteName: "privatebase",
|
||||
NormalizedName: "example.com/privatebase",
|
||||
FullName: "example.com/privatebase",
|
||||
AmbiguousName: "",
|
||||
Hostname: "example.com",
|
||||
},
|
||||
{
|
||||
RemoteName: "private/moonbase",
|
||||
NormalizedName: "example.com:8000/private/moonbase",
|
||||
FullName: "example.com:8000/private/moonbase",
|
||||
AmbiguousName: "",
|
||||
Hostname: "example.com:8000",
|
||||
},
|
||||
{
|
||||
RemoteName: "privatebasee",
|
||||
NormalizedName: "example.com:8000/privatebasee",
|
||||
FullName: "example.com:8000/privatebasee",
|
||||
AmbiguousName: "",
|
||||
Hostname: "example.com:8000",
|
||||
},
|
||||
{
|
||||
RemoteName: "library/ubuntu-12.04-base",
|
||||
NormalizedName: "ubuntu-12.04-base",
|
||||
FullName: "docker.io/library/ubuntu-12.04-base",
|
||||
AmbiguousName: "index.docker.io/library/ubuntu-12.04-base",
|
||||
Hostname: "docker.io",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tcase := range tcases {
|
||||
refStrings := []string{tcase.NormalizedName, tcase.FullName}
|
||||
if tcase.AmbiguousName != "" {
|
||||
refStrings = append(refStrings, tcase.AmbiguousName)
|
||||
}
|
||||
|
||||
var refs []Named
|
||||
for _, r := range refStrings {
|
||||
named, err := ParseNamed(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs = append(refs, named)
|
||||
named, err = WithName(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs = append(refs, named)
|
||||
}
|
||||
|
||||
for _, r := range refs {
|
||||
if expected, actual := tcase.NormalizedName, r.Name(); expected != actual {
|
||||
t.Fatalf("Invalid normalized reference for %q. Expected %q, got %q", r, expected, actual)
|
||||
}
|
||||
if expected, actual := tcase.FullName, r.FullName(); expected != actual {
|
||||
t.Fatalf("Invalid normalized reference for %q. Expected %q, got %q", r, expected, actual)
|
||||
}
|
||||
if expected, actual := tcase.Hostname, r.Hostname(); expected != actual {
|
||||
t.Fatalf("Invalid hostname for %q. Expected %q, got %q", r, expected, actual)
|
||||
}
|
||||
if expected, actual := tcase.RemoteName, r.RemoteName(); expected != actual {
|
||||
t.Fatalf("Invalid remoteName for %q. Expected %q, got %q", r, expected, actual)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReferenceWithTagAndDigest(t *testing.T) {
|
||||
ref, err := ParseNamed("busybox:latest@sha256:86e0e091d0da6bde2456dbb48306f3956bbeb2eae1b5b9a43045843f69fe4aaa")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, isTagged := ref.(NamedTagged); isTagged {
|
||||
t.Fatalf("Reference from %q should not support tag", ref)
|
||||
}
|
||||
if _, isCanonical := ref.(Canonical); !isCanonical {
|
||||
t.Fatalf("Reference from %q should not support digest", ref)
|
||||
}
|
||||
if expected, actual := "busybox@sha256:86e0e091d0da6bde2456dbb48306f3956bbeb2eae1b5b9a43045843f69fe4aaa", ref.String(); actual != expected {
|
||||
t.Fatalf("Invalid parsed reference for %q: expected %q, got %q", ref, expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidReferenceComponents(t *testing.T) {
|
||||
if _, err := WithName("-foo"); err == nil {
|
||||
t.Fatal("Expected WithName to detect invalid name")
|
||||
}
|
||||
ref, err := WithName("busybox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := WithTag(ref, "-foo"); err == nil {
|
||||
t.Fatal("Expected WithName to detect invalid tag")
|
||||
}
|
||||
if _, err := WithDigest(ref, digest.Digest("foo")); err == nil {
|
||||
t.Fatal("Expected WithName to detect invalid digest")
|
||||
}
|
||||
}
|
||||
298
vendor/github.com/hyperhq/hypercli/reference/store.go
generated
vendored
Normal file
298
vendor/github.com/hyperhq/hypercli/reference/store.go
generated
vendored
Normal file
@@ -0,0 +1,298 @@
|
||||
package reference
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/docker/distribution/digest"
|
||||
"github.com/hyperhq/hypercli/image"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrDoesNotExist is returned if a reference is not found in the
|
||||
// store.
|
||||
ErrDoesNotExist = errors.New("reference does not exist")
|
||||
)
|
||||
|
||||
// An Association is a tuple associating a reference with an image ID.
|
||||
type Association struct {
|
||||
Ref Named
|
||||
ImageID image.ID
|
||||
}
|
||||
|
||||
// Store provides the set of methods which can operate on a tag store.
|
||||
type Store interface {
|
||||
References(id image.ID) []Named
|
||||
ReferencesByName(ref Named) []Association
|
||||
AddTag(ref Named, id image.ID, force bool) error
|
||||
AddDigest(ref Canonical, id image.ID, force bool) error
|
||||
Delete(ref Named) (bool, error)
|
||||
Get(ref Named) (image.ID, error)
|
||||
}
|
||||
|
||||
type store struct {
|
||||
mu sync.RWMutex
|
||||
// jsonPath is the path to the file where the serialized tag data is
|
||||
// stored.
|
||||
jsonPath string
|
||||
// Repositories is a map of repositories, indexed by name.
|
||||
Repositories map[string]repository
|
||||
// referencesByIDCache is a cache of references indexed by ID, to speed
|
||||
// up References.
|
||||
referencesByIDCache map[image.ID]map[string]Named
|
||||
}
|
||||
|
||||
// Repository maps tags to image IDs. The key is a a stringified Reference,
|
||||
// including the repository name.
|
||||
type repository map[string]image.ID
|
||||
|
||||
type lexicalRefs []Named
|
||||
|
||||
func (a lexicalRefs) Len() int { return len(a) }
|
||||
func (a lexicalRefs) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
func (a lexicalRefs) Less(i, j int) bool { return a[i].String() < a[j].String() }
|
||||
|
||||
type lexicalAssociations []Association
|
||||
|
||||
func (a lexicalAssociations) Len() int { return len(a) }
|
||||
func (a lexicalAssociations) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
func (a lexicalAssociations) Less(i, j int) bool { return a[i].Ref.String() < a[j].Ref.String() }
|
||||
|
||||
// NewReferenceStore creates a new reference store, tied to a file path where
|
||||
// the set of references are serialized in JSON format.
|
||||
func NewReferenceStore(jsonPath string) (Store, error) {
|
||||
abspath, err := filepath.Abs(jsonPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
store := &store{
|
||||
jsonPath: abspath,
|
||||
Repositories: make(map[string]repository),
|
||||
referencesByIDCache: make(map[image.ID]map[string]Named),
|
||||
}
|
||||
// Load the json file if it exists, otherwise create it.
|
||||
if err := store.reload(); os.IsNotExist(err) {
|
||||
if err := store.save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// AddTag adds a tag reference to the store. If force is set to true, existing
|
||||
// references can be overwritten. This only works for tags, not digests.
|
||||
func (store *store) AddTag(ref Named, id image.ID, force bool) error {
|
||||
if _, isCanonical := ref.(Canonical); isCanonical {
|
||||
return errors.New("refusing to create a tag with a digest reference")
|
||||
}
|
||||
return store.addReference(WithDefaultTag(ref), id, force)
|
||||
}
|
||||
|
||||
// AddDigest adds a digest reference to the store.
|
||||
func (store *store) AddDigest(ref Canonical, id image.ID, force bool) error {
|
||||
return store.addReference(ref, id, force)
|
||||
}
|
||||
|
||||
func (store *store) addReference(ref Named, id image.ID, force bool) error {
|
||||
if ref.Name() == string(digest.Canonical) {
|
||||
return errors.New("refusing to create an ambiguous tag using digest algorithm as name")
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
repository, exists := store.Repositories[ref.Name()]
|
||||
if !exists || repository == nil {
|
||||
repository = make(map[string]image.ID)
|
||||
store.Repositories[ref.Name()] = repository
|
||||
}
|
||||
|
||||
refStr := ref.String()
|
||||
oldID, exists := repository[refStr]
|
||||
|
||||
if exists {
|
||||
// force only works for tags
|
||||
if digested, isDigest := ref.(Canonical); isDigest {
|
||||
return fmt.Errorf("Cannot overwrite digest %s", digested.Digest().String())
|
||||
}
|
||||
|
||||
if !force {
|
||||
return fmt.Errorf("Conflict: Tag %s is already set to image %s, if you want to replace it, please use -f option", ref.String(), oldID.String())
|
||||
}
|
||||
|
||||
if store.referencesByIDCache[oldID] != nil {
|
||||
delete(store.referencesByIDCache[oldID], refStr)
|
||||
if len(store.referencesByIDCache[oldID]) == 0 {
|
||||
delete(store.referencesByIDCache, oldID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repository[refStr] = id
|
||||
if store.referencesByIDCache[id] == nil {
|
||||
store.referencesByIDCache[id] = make(map[string]Named)
|
||||
}
|
||||
store.referencesByIDCache[id][refStr] = ref
|
||||
|
||||
return store.save()
|
||||
}
|
||||
|
||||
// Delete deletes a reference from the store. It returns true if a deletion
|
||||
// happened, or false otherwise.
|
||||
func (store *store) Delete(ref Named) (bool, error) {
|
||||
ref = WithDefaultTag(ref)
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
repoName := ref.Name()
|
||||
|
||||
repository, exists := store.Repositories[repoName]
|
||||
if !exists {
|
||||
return false, ErrDoesNotExist
|
||||
}
|
||||
|
||||
refStr := ref.String()
|
||||
if id, exists := repository[refStr]; exists {
|
||||
delete(repository, refStr)
|
||||
if len(repository) == 0 {
|
||||
delete(store.Repositories, repoName)
|
||||
}
|
||||
if store.referencesByIDCache[id] != nil {
|
||||
delete(store.referencesByIDCache[id], refStr)
|
||||
if len(store.referencesByIDCache[id]) == 0 {
|
||||
delete(store.referencesByIDCache, id)
|
||||
}
|
||||
}
|
||||
return true, store.save()
|
||||
}
|
||||
|
||||
return false, ErrDoesNotExist
|
||||
}
|
||||
|
||||
// Get retrieves an item from the store by
|
||||
func (store *store) Get(ref Named) (image.ID, error) {
|
||||
ref = WithDefaultTag(ref)
|
||||
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
|
||||
repository, exists := store.Repositories[ref.Name()]
|
||||
if !exists || repository == nil {
|
||||
return "", ErrDoesNotExist
|
||||
}
|
||||
|
||||
id, exists := repository[ref.String()]
|
||||
if !exists {
|
||||
return "", ErrDoesNotExist
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// References returns a slice of references to the given image ID. The slice
|
||||
// will be nil if there are no references to this image ID.
|
||||
func (store *store) References(id image.ID) []Named {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
|
||||
// Convert the internal map to an array for two reasons:
|
||||
// 1) We must not return a mutable
|
||||
// 2) It would be ugly to expose the extraneous map keys to callers.
|
||||
|
||||
var references []Named
|
||||
for _, ref := range store.referencesByIDCache[id] {
|
||||
references = append(references, ref)
|
||||
}
|
||||
|
||||
sort.Sort(lexicalRefs(references))
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
// ReferencesByName returns the references for a given repository name.
|
||||
// If there are no references known for this repository name,
|
||||
// ReferencesByName returns nil.
|
||||
func (store *store) ReferencesByName(ref Named) []Association {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
|
||||
repository, exists := store.Repositories[ref.Name()]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
var associations []Association
|
||||
for refStr, refID := range repository {
|
||||
ref, err := ParseNamed(refStr)
|
||||
if err != nil {
|
||||
// Should never happen
|
||||
return nil
|
||||
}
|
||||
associations = append(associations,
|
||||
Association{
|
||||
Ref: ref,
|
||||
ImageID: refID,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Sort(lexicalAssociations(associations))
|
||||
|
||||
return associations
|
||||
}
|
||||
|
||||
func (store *store) save() error {
|
||||
// Store the json
|
||||
jsonData, err := json.Marshal(store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tempFilePath := store.jsonPath + ".tmp"
|
||||
|
||||
if err := ioutil.WriteFile(tempFilePath, jsonData, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Rename(tempFilePath, store.jsonPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) reload() error {
|
||||
f, err := os.Open(store.jsonPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := json.NewDecoder(f).Decode(&store); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, repository := range store.Repositories {
|
||||
for refStr, refID := range repository {
|
||||
ref, err := ParseNamed(refStr)
|
||||
if err != nil {
|
||||
// Should never happen
|
||||
continue
|
||||
}
|
||||
if store.referencesByIDCache[refID] == nil {
|
||||
store.referencesByIDCache[refID] = make(map[string]Named)
|
||||
}
|
||||
store.referencesByIDCache[refID][refStr] = ref
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
356
vendor/github.com/hyperhq/hypercli/reference/store_test.go
generated
vendored
Normal file
356
vendor/github.com/hyperhq/hypercli/reference/store_test.go
generated
vendored
Normal file
@@ -0,0 +1,356 @@
|
||||
package reference
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hyperhq/hypercli/image"
|
||||
)
|
||||
|
||||
var (
|
||||
saveLoadTestCases = map[string]image.ID{
|
||||
"registry:5000/foobar:HEAD": "sha256:470022b8af682154f57a2163d030eb369549549cba00edc69e1b99b46bb924d6",
|
||||
"registry:5000/foobar:alternate": "sha256:ae300ebc4a4f00693702cfb0a5e0b7bc527b353828dc86ad09fb95c8a681b793",
|
||||
"registry:5000/foobar:latest": "sha256:6153498b9ac00968d71b66cca4eac37e990b5f9eb50c26877eb8799c8847451b",
|
||||
"registry:5000/foobar:master": "sha256:6c9917af4c4e05001b346421959d7ea81b6dc9d25718466a37a6add865dfd7fc",
|
||||
"jess/hollywood:latest": "sha256:ae7a5519a0a55a2d4ef20ddcbd5d0ca0888a1f7ab806acc8e2a27baf46f529fe",
|
||||
"registry@sha256:367eb40fd0330a7e464777121e39d2f5b3e8e23a1e159342e53ab05c9e4d94e6": "sha256:24126a56805beb9711be5f4590cc2eb55ab8d4a85ebd618eed72bb19fc50631c",
|
||||
"busybox:latest": "sha256:91e54dfb11794fad694460162bf0cb0a4fa710cfa3f60979c177d920813e267c",
|
||||
}
|
||||
|
||||
marshalledSaveLoadTestCases = []byte(`{"Repositories":{"busybox":{"busybox:latest":"sha256:91e54dfb11794fad694460162bf0cb0a4fa710cfa3f60979c177d920813e267c"},"jess/hollywood":{"jess/hollywood:latest":"sha256:ae7a5519a0a55a2d4ef20ddcbd5d0ca0888a1f7ab806acc8e2a27baf46f529fe"},"registry":{"registry@sha256:367eb40fd0330a7e464777121e39d2f5b3e8e23a1e159342e53ab05c9e4d94e6":"sha256:24126a56805beb9711be5f4590cc2eb55ab8d4a85ebd618eed72bb19fc50631c"},"registry:5000/foobar":{"registry:5000/foobar:HEAD":"sha256:470022b8af682154f57a2163d030eb369549549cba00edc69e1b99b46bb924d6","registry:5000/foobar:alternate":"sha256:ae300ebc4a4f00693702cfb0a5e0b7bc527b353828dc86ad09fb95c8a681b793","registry:5000/foobar:latest":"sha256:6153498b9ac00968d71b66cca4eac37e990b5f9eb50c26877eb8799c8847451b","registry:5000/foobar:master":"sha256:6c9917af4c4e05001b346421959d7ea81b6dc9d25718466a37a6add865dfd7fc"}}}`)
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
jsonFile, err := ioutil.TempFile("", "tag-store-test")
|
||||
if err != nil {
|
||||
t.Fatalf("error creating temp file: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(jsonFile.Name())
|
||||
|
||||
// Write canned json to the temp file
|
||||
_, err = jsonFile.Write(marshalledSaveLoadTestCases)
|
||||
if err != nil {
|
||||
t.Fatalf("error writing to temp file: %v", err)
|
||||
}
|
||||
jsonFile.Close()
|
||||
|
||||
store, err := NewReferenceStore(jsonFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("error creating tag store: %v", err)
|
||||
}
|
||||
|
||||
for refStr, expectedID := range saveLoadTestCases {
|
||||
ref, err := ParseNamed(refStr)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse reference: %v", err)
|
||||
}
|
||||
id, err := store.Get(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("could not find reference %s: %v", refStr, err)
|
||||
}
|
||||
if id != expectedID {
|
||||
t.Fatalf("expected %s - got %s", expectedID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave(t *testing.T) {
|
||||
jsonFile, err := ioutil.TempFile("", "tag-store-test")
|
||||
if err != nil {
|
||||
t.Fatalf("error creating temp file: %v", err)
|
||||
}
|
||||
_, err = jsonFile.Write([]byte(`{}`))
|
||||
jsonFile.Close()
|
||||
defer os.RemoveAll(jsonFile.Name())
|
||||
|
||||
store, err := NewReferenceStore(jsonFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("error creating tag store: %v", err)
|
||||
}
|
||||
|
||||
for refStr, id := range saveLoadTestCases {
|
||||
ref, err := ParseNamed(refStr)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse reference: %v", err)
|
||||
}
|
||||
if canonical, ok := ref.(Canonical); ok {
|
||||
err = store.AddDigest(canonical, id, false)
|
||||
if err != nil {
|
||||
t.Fatalf("could not add digest reference %s: %v", refStr, err)
|
||||
}
|
||||
} else {
|
||||
err = store.AddTag(ref, id, false)
|
||||
if err != nil {
|
||||
t.Fatalf("could not add reference %s: %v", refStr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jsonBytes, err := ioutil.ReadFile(jsonFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("could not read json file: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(jsonBytes, marshalledSaveLoadTestCases) {
|
||||
t.Fatalf("save output did not match expectations\nexpected:\n%s\ngot:\n%s", marshalledSaveLoadTestCases, jsonBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDeleteGet(t *testing.T) {
|
||||
jsonFile, err := ioutil.TempFile("", "tag-store-test")
|
||||
if err != nil {
|
||||
t.Fatalf("error creating temp file: %v", err)
|
||||
}
|
||||
_, err = jsonFile.Write([]byte(`{}`))
|
||||
jsonFile.Close()
|
||||
defer os.RemoveAll(jsonFile.Name())
|
||||
|
||||
store, err := NewReferenceStore(jsonFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("error creating tag store: %v", err)
|
||||
}
|
||||
|
||||
testImageID1 := image.ID("sha256:9655aef5fd742a1b4e1b7b163aa9f1c76c186304bf39102283d80927c916ca9c")
|
||||
testImageID2 := image.ID("sha256:9655aef5fd742a1b4e1b7b163aa9f1c76c186304bf39102283d80927c916ca9d")
|
||||
testImageID3 := image.ID("sha256:9655aef5fd742a1b4e1b7b163aa9f1c76c186304bf39102283d80927c916ca9e")
|
||||
|
||||
// Try adding a reference with no tag or digest
|
||||
nameOnly, err := WithName("username/repo")
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse reference: %v", err)
|
||||
}
|
||||
if err = store.AddTag(nameOnly, testImageID1, false); err != nil {
|
||||
t.Fatalf("error adding to store: %v", err)
|
||||
}
|
||||
|
||||
// Add a few references
|
||||
ref1, err := ParseNamed("username/repo1:latest")
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse reference: %v", err)
|
||||
}
|
||||
if err = store.AddTag(ref1, testImageID1, false); err != nil {
|
||||
t.Fatalf("error adding to store: %v", err)
|
||||
}
|
||||
|
||||
ref2, err := ParseNamed("username/repo1:old")
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse reference: %v", err)
|
||||
}
|
||||
if err = store.AddTag(ref2, testImageID2, false); err != nil {
|
||||
t.Fatalf("error adding to store: %v", err)
|
||||
}
|
||||
|
||||
ref3, err := ParseNamed("username/repo1:alias")
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse reference: %v", err)
|
||||
}
|
||||
if err = store.AddTag(ref3, testImageID1, false); err != nil {
|
||||
t.Fatalf("error adding to store: %v", err)
|
||||
}
|
||||
|
||||
ref4, err := ParseNamed("username/repo2:latest")
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse reference: %v", err)
|
||||
}
|
||||
if err = store.AddTag(ref4, testImageID2, false); err != nil {
|
||||
t.Fatalf("error adding to store: %v", err)
|
||||
}
|
||||
|
||||
ref5, err := ParseNamed("username/repo3@sha256:58153dfb11794fad694460162bf0cb0a4fa710cfa3f60979c177d920813e267c")
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse reference: %v", err)
|
||||
}
|
||||
if err = store.AddDigest(ref5.(Canonical), testImageID2, false); err != nil {
|
||||
t.Fatalf("error adding to store: %v", err)
|
||||
}
|
||||
|
||||
// Attempt to overwrite with force == false
|
||||
if err = store.AddTag(ref4, testImageID3, false); err == nil || !strings.HasPrefix(err.Error(), "Conflict:") {
|
||||
t.Fatalf("did not get expected error on overwrite attempt - got %v", err)
|
||||
}
|
||||
// Repeat to overwrite with force == true
|
||||
if err = store.AddTag(ref4, testImageID3, true); err != nil {
|
||||
t.Fatalf("failed to force tag overwrite: %v", err)
|
||||
}
|
||||
|
||||
// Check references so far
|
||||
id, err := store.Get(nameOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if id != testImageID1 {
|
||||
t.Fatalf("id mismatch: got %s instead of %s", id.String(), testImageID1.String())
|
||||
}
|
||||
|
||||
id, err = store.Get(ref1)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if id != testImageID1 {
|
||||
t.Fatalf("id mismatch: got %s instead of %s", id.String(), testImageID1.String())
|
||||
}
|
||||
|
||||
id, err = store.Get(ref2)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if id != testImageID2 {
|
||||
t.Fatalf("id mismatch: got %s instead of %s", id.String(), testImageID2.String())
|
||||
}
|
||||
|
||||
id, err = store.Get(ref3)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if id != testImageID1 {
|
||||
t.Fatalf("id mismatch: got %s instead of %s", id.String(), testImageID1.String())
|
||||
}
|
||||
|
||||
id, err = store.Get(ref4)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if id != testImageID3 {
|
||||
t.Fatalf("id mismatch: got %s instead of %s", id.String(), testImageID3.String())
|
||||
}
|
||||
|
||||
id, err = store.Get(ref5)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if id != testImageID2 {
|
||||
t.Fatalf("id mismatch: got %s instead of %s", id.String(), testImageID3.String())
|
||||
}
|
||||
|
||||
// Get should return ErrDoesNotExist for a nonexistent repo
|
||||
nonExistRepo, err := ParseNamed("username/nonexistrepo:latest")
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse reference: %v", err)
|
||||
}
|
||||
if _, err = store.Get(nonExistRepo); err != ErrDoesNotExist {
|
||||
t.Fatal("Expected ErrDoesNotExist from Get")
|
||||
}
|
||||
|
||||
// Get should return ErrDoesNotExist for a nonexistent tag
|
||||
nonExistTag, err := ParseNamed("username/repo1:nonexist")
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse reference: %v", err)
|
||||
}
|
||||
if _, err = store.Get(nonExistTag); err != ErrDoesNotExist {
|
||||
t.Fatal("Expected ErrDoesNotExist from Get")
|
||||
}
|
||||
|
||||
// Check References
|
||||
refs := store.References(testImageID1)
|
||||
if len(refs) != 3 {
|
||||
t.Fatal("unexpected number of references")
|
||||
}
|
||||
// Looking for the references in this order verifies that they are
|
||||
// returned lexically sorted.
|
||||
if refs[0].String() != ref3.String() {
|
||||
t.Fatalf("unexpected reference: %v", refs[0].String())
|
||||
}
|
||||
if refs[1].String() != ref1.String() {
|
||||
t.Fatalf("unexpected reference: %v", refs[1].String())
|
||||
}
|
||||
if refs[2].String() != nameOnly.String()+":latest" {
|
||||
t.Fatalf("unexpected reference: %v", refs[2].String())
|
||||
}
|
||||
|
||||
// Check ReferencesByName
|
||||
repoName, err := WithName("username/repo1")
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse reference: %v", err)
|
||||
}
|
||||
associations := store.ReferencesByName(repoName)
|
||||
if len(associations) != 3 {
|
||||
t.Fatal("unexpected number of associations")
|
||||
}
|
||||
// Looking for the associations in this order verifies that they are
|
||||
// returned lexically sorted.
|
||||
if associations[0].Ref.String() != ref3.String() {
|
||||
t.Fatalf("unexpected reference: %v", associations[0].Ref.String())
|
||||
}
|
||||
if associations[0].ImageID != testImageID1 {
|
||||
t.Fatalf("unexpected reference: %v", associations[0].Ref.String())
|
||||
}
|
||||
if associations[1].Ref.String() != ref1.String() {
|
||||
t.Fatalf("unexpected reference: %v", associations[1].Ref.String())
|
||||
}
|
||||
if associations[1].ImageID != testImageID1 {
|
||||
t.Fatalf("unexpected reference: %v", associations[1].Ref.String())
|
||||
}
|
||||
if associations[2].Ref.String() != ref2.String() {
|
||||
t.Fatalf("unexpected reference: %v", associations[2].Ref.String())
|
||||
}
|
||||
if associations[2].ImageID != testImageID2 {
|
||||
t.Fatalf("unexpected reference: %v", associations[2].Ref.String())
|
||||
}
|
||||
|
||||
// Delete should return ErrDoesNotExist for a nonexistent repo
|
||||
if _, err = store.Delete(nonExistRepo); err != ErrDoesNotExist {
|
||||
t.Fatal("Expected ErrDoesNotExist from Delete")
|
||||
}
|
||||
|
||||
// Delete should return ErrDoesNotExist for a nonexistent tag
|
||||
if _, err = store.Delete(nonExistTag); err != ErrDoesNotExist {
|
||||
t.Fatal("Expected ErrDoesNotExist from Delete")
|
||||
}
|
||||
|
||||
// Delete a few references
|
||||
if deleted, err := store.Delete(ref1); err != nil || deleted != true {
|
||||
t.Fatal("Delete failed")
|
||||
}
|
||||
if _, err := store.Get(ref1); err != ErrDoesNotExist {
|
||||
t.Fatal("Expected ErrDoesNotExist from Get")
|
||||
}
|
||||
if deleted, err := store.Delete(ref5); err != nil || deleted != true {
|
||||
t.Fatal("Delete failed")
|
||||
}
|
||||
if _, err := store.Get(ref5); err != ErrDoesNotExist {
|
||||
t.Fatal("Expected ErrDoesNotExist from Get")
|
||||
}
|
||||
if deleted, err := store.Delete(nameOnly); err != nil || deleted != true {
|
||||
t.Fatal("Delete failed")
|
||||
}
|
||||
if _, err := store.Get(nameOnly); err != ErrDoesNotExist {
|
||||
t.Fatal("Expected ErrDoesNotExist from Get")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidTags(t *testing.T) {
|
||||
tmpDir, err := ioutil.TempDir("", "tag-store-test")
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
store, err := NewReferenceStore(filepath.Join(tmpDir, "repositories.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("error creating tag store: %v", err)
|
||||
}
|
||||
id := image.ID("sha256:470022b8af682154f57a2163d030eb369549549cba00edc69e1b99b46bb924d6")
|
||||
|
||||
// sha256 as repo name
|
||||
ref, err := ParseNamed("sha256:abc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = store.AddTag(ref, id, true)
|
||||
if err == nil {
|
||||
t.Fatalf("expected setting tag %q to fail", ref)
|
||||
}
|
||||
|
||||
// setting digest as a tag
|
||||
ref, err = ParseNamed("registry@sha256:367eb40fd0330a7e464777121e39d2f5b3e8e23a1e159342e53ab05c9e4d94e6")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = store.AddTag(ref, id, true)
|
||||
if err == nil {
|
||||
t.Fatalf("expected setting digest %q to fail", ref)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user