mirror of https://github.com/helm/helm.git
feat(helm): generate index file for repository
This commit is contained in:
parent
d36615e3cb
commit
4bb36c89ab
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/gosuri/uitable"
|
"github.com/gosuri/uitable"
|
||||||
"github.com/kubernetes/helm/pkg/repo"
|
"github.com/kubernetes/helm/pkg/repo"
|
||||||
|
|
@ -15,6 +16,7 @@ func init() {
|
||||||
repoCmd.AddCommand(repoAddCmd)
|
repoCmd.AddCommand(repoAddCmd)
|
||||||
repoCmd.AddCommand(repoListCmd)
|
repoCmd.AddCommand(repoListCmd)
|
||||||
repoCmd.AddCommand(repoRemoveCmd)
|
repoCmd.AddCommand(repoRemoveCmd)
|
||||||
|
repoCmd.AddCommand(repoIndexCmd)
|
||||||
RootCommand.AddCommand(repoCmd)
|
RootCommand.AddCommand(repoCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +43,12 @@ var repoRemoveCmd = &cobra.Command{
|
||||||
RunE: runRepoRemove,
|
RunE: runRepoRemove,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var repoIndexCmd = &cobra.Command{
|
||||||
|
Use: "index [flags] [DIR]",
|
||||||
|
Short: "generate an index file for a chart repository given a directory",
|
||||||
|
RunE: runRepoIndex,
|
||||||
|
}
|
||||||
|
|
||||||
func runRepoAdd(cmd *cobra.Command, args []string) error {
|
func runRepoAdd(cmd *cobra.Command, args []string) error {
|
||||||
if err := checkArgsLength(2, len(args), "name for the chart repository", "the url of the chart repository"); err != nil {
|
if err := checkArgsLength(2, len(args), "name for the chart repository", "the url of the chart repository"); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -87,6 +95,35 @@ func runRepoRemove(cmd *cobra.Command, args []string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runRepoIndex(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := checkArgsLength(1, len(args), "path to a directory"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := filepath.Abs(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := index(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func index(dir string) error {
|
||||||
|
chartRepo, err := repo.LoadChartRepository(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := chartRepo.Index(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func removeRepoLine(name string) error {
|
func removeRepoLine(name string) error {
|
||||||
r, err := repo.LoadRepositoriesFile(repositoriesFile())
|
r, err := repo.LoadRepositoriesFile(repositoriesFile())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ func searchChartRefsForPattern(search string, chartRefs map[string]*repo.ChartRe
|
||||||
matches = append(matches, k)
|
matches = append(matches, k)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, keyword := range c.Keywords {
|
for _, keyword := range c.Chartfile.Keywords {
|
||||||
if strings.Contains(keyword, search) {
|
if strings.Contains(keyword, search) {
|
||||||
matches = append(matches, k)
|
matches = append(matches, k)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kubernetes/helm/pkg/chart"
|
"github.com/kubernetes/helm/pkg/chart"
|
||||||
"gopkg.in/yaml.v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var localRepoPath string
|
var localRepoPath string
|
||||||
|
|
@ -55,22 +54,6 @@ func AddChartToLocalRepo(ch *chart.Chart, path string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadIndexFile takes a file at the given path and returns an IndexFile object
|
|
||||||
func LoadIndexFile(path string) (*IndexFile, error) {
|
|
||||||
b, err := ioutil.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: change variable name - y is not helpful :P
|
|
||||||
var y IndexFile
|
|
||||||
err = yaml.Unmarshal(b, &y)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &y, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reindex adds an entry to the index file at the given path
|
// Reindex adds an entry to the index file at the given path
|
||||||
func Reindex(ch *chart.Chart, path string) error {
|
func Reindex(ch *chart.Chart, path string) error {
|
||||||
name := ch.Chartfile().Name + "-" + ch.Chartfile().Version
|
name := ch.Chartfile().Name + "-" + ch.Chartfile().Version
|
||||||
|
|
@ -88,7 +71,7 @@ func Reindex(ch *chart.Chart, path string) error {
|
||||||
if !found {
|
if !found {
|
||||||
url := "localhost:8879/charts/" + name + ".tgz"
|
url := "localhost:8879/charts/" + name + ".tgz"
|
||||||
|
|
||||||
out, err := y.insertChartEntry(name, url)
|
out, err := y.addEntry(name, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -97,29 +80,3 @@ func Reindex(ch *chart.Chart, path string) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalYAML unmarshals the index file
|
|
||||||
func (i *IndexFile) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
||||||
var refs map[string]*ChartRef
|
|
||||||
if err := unmarshal(&refs); err != nil {
|
|
||||||
if _, ok := err.(*yaml.TypeError); !ok {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i.Entries = refs
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *IndexFile) insertChartEntry(name string, url string) ([]byte, error) {
|
|
||||||
if i.Entries == nil {
|
|
||||||
i.Entries = make(map[string]*ChartRef)
|
|
||||||
}
|
|
||||||
entry := ChartRef{Name: name, URL: url}
|
|
||||||
i.Entries[name] = &entry
|
|
||||||
out, err := yaml.Marshal(&i.Entries)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
package repo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
const testfile = "testdata/local-index.yaml"
|
|
||||||
|
|
||||||
func TestLoadIndexFile(t *testing.T) {
|
|
||||||
cf, err := LoadIndexFile(testfile)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Failed to load index file: %s", err)
|
|
||||||
}
|
|
||||||
if len(cf.Entries) != 2 {
|
|
||||||
t.Errorf("Expected 2 entries in the index file, but got %d", len(cf.Entries))
|
|
||||||
}
|
|
||||||
nginx := false
|
|
||||||
alpine := false
|
|
||||||
for k, e := range cf.Entries {
|
|
||||||
if k == "nginx-0.1.0" {
|
|
||||||
if e.Name == "nginx" {
|
|
||||||
if len(e.Keywords) == 3 {
|
|
||||||
nginx = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if k == "alpine-1.0.0" {
|
|
||||||
if e.Name == "alpine" {
|
|
||||||
if len(e.Keywords) == 4 {
|
|
||||||
alpine = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !nginx {
|
|
||||||
t.Errorf("nginx entry was not decoded properly")
|
|
||||||
}
|
|
||||||
if !alpine {
|
|
||||||
t.Errorf("alpine entry was not decoded properly")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
155
pkg/repo/repo.go
155
pkg/repo/repo.go
|
|
@ -1,12 +1,41 @@
|
||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kubernetes/helm/pkg/chart"
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RepoFile represents the .repositories file in $HELM_HOME
|
var indexPath = "index.yaml"
|
||||||
|
|
||||||
|
// ChartRepository represents a chart repository
|
||||||
|
type ChartRepository struct {
|
||||||
|
RootPath string
|
||||||
|
URL string // URL of repository
|
||||||
|
ChartPaths []string
|
||||||
|
IndexFile *IndexFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexFile represents the index file in a chart repository
|
||||||
|
type IndexFile struct {
|
||||||
|
Entries map[string]*ChartRef
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChartRef represents a chart entry in the IndexFile
|
||||||
|
type ChartRef struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Created string `yaml:"created,omitempty"`
|
||||||
|
Removed bool `yaml:"removed,omitempty"`
|
||||||
|
Chartfile chart.Chartfile `yaml:"chartfile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoFile represents the repositories.yaml file in $HELM_HOME
|
||||||
type RepoFile struct {
|
type RepoFile struct {
|
||||||
Repositories map[string]string
|
Repositories map[string]string
|
||||||
}
|
}
|
||||||
|
|
@ -38,3 +67,127 @@ func (rf *RepoFile) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||||
rf.Repositories = repos
|
rf.Repositories = repos
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadChartRepository takes in a path to a local chart repository
|
||||||
|
// which contains packaged charts and an index.yaml file
|
||||||
|
//
|
||||||
|
// This function evaluates the contents of the directory and
|
||||||
|
// returns a ChartRepository
|
||||||
|
func LoadChartRepository(dir string) (*ChartRepository, error) {
|
||||||
|
dirInfo, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dirInfo.IsDir() {
|
||||||
|
return nil, errors.New(dir + "is not a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &ChartRepository{RootPath: dir}
|
||||||
|
|
||||||
|
filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
|
||||||
|
if !f.IsDir() {
|
||||||
|
if strings.Contains(f.Name(), "index.yaml") {
|
||||||
|
i, err := LoadIndexFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.IndexFile = i
|
||||||
|
} else {
|
||||||
|
// TODO: check for tgz extension
|
||||||
|
r.ChartPaths = append(r.ChartPaths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalYAML unmarshals the index file
|
||||||
|
func (i *IndexFile) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||||
|
var refs map[string]*ChartRef
|
||||||
|
if err := unmarshal(&refs); err != nil {
|
||||||
|
if _, ok := err.(*yaml.TypeError); !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i.Entries = refs
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IndexFile) addEntry(name string, url string) ([]byte, error) {
|
||||||
|
if i.Entries == nil {
|
||||||
|
i.Entries = make(map[string]*ChartRef)
|
||||||
|
}
|
||||||
|
entry := ChartRef{Name: name, URL: url}
|
||||||
|
i.Entries[name] = &entry
|
||||||
|
out, err := yaml.Marshal(&i.Entries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadIndexFile takes a file at the given path and returns an IndexFile object
|
||||||
|
func LoadIndexFile(path string) (*IndexFile, error) {
|
||||||
|
b, err := ioutil.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexfile IndexFile
|
||||||
|
err = yaml.Unmarshal(b, &indexfile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &indexfile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChartRepository) Index() error {
|
||||||
|
if r.IndexFile == nil {
|
||||||
|
r.IndexFile = &IndexFile{Entries: make(map[string]*ChartRef)}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range r.ChartPaths {
|
||||||
|
ch, err := chart.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
chartfile := ch.Chartfile()
|
||||||
|
|
||||||
|
key := chartfile.Name + "-" + chartfile.Version
|
||||||
|
if r.IndexFile.Entries == nil {
|
||||||
|
r.IndexFile.Entries = make(map[string]*ChartRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &ChartRef{Chartfile: *chartfile, Name: chartfile.Name, URL: "", Created: "", Removed: false}
|
||||||
|
|
||||||
|
//TODO: generate hash of contents of chart and add to the entry
|
||||||
|
//TODO: Set created timestamp
|
||||||
|
|
||||||
|
r.IndexFile.Entries[key] = entry
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.saveIndexFile(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChartRepository) saveIndexFile() error {
|
||||||
|
index, err := yaml.Marshal(&r.IndexFile.Entries)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = ioutil.WriteFile(filepath.Join(r.RootPath, indexPath), index, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testfile = "testdata/local-index.yaml"
|
||||||
|
const testRepositoriesFile = "testdata/repositories.yaml"
|
||||||
|
const testRepository = "testdata/repository"
|
||||||
|
|
||||||
|
func TestLoadIndexFile(t *testing.T) {
|
||||||
|
cf, err := LoadIndexFile(testfile)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to load index file: %s", err)
|
||||||
|
}
|
||||||
|
if len(cf.Entries) != 2 {
|
||||||
|
t.Errorf("Expected 2 entries in the index file, but got %d", len(cf.Entries))
|
||||||
|
}
|
||||||
|
nginx := false
|
||||||
|
alpine := false
|
||||||
|
for k, e := range cf.Entries {
|
||||||
|
if k == "nginx-0.1.0" {
|
||||||
|
if e.Name == "nginx" {
|
||||||
|
if len(e.Chartfile.Keywords) == 3 {
|
||||||
|
nginx = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if k == "alpine-1.0.0" {
|
||||||
|
if e.Name == "alpine" {
|
||||||
|
if len(e.Chartfile.Keywords) == 4 {
|
||||||
|
alpine = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !nginx {
|
||||||
|
t.Errorf("nginx entry was not decoded properly")
|
||||||
|
}
|
||||||
|
if !alpine {
|
||||||
|
t.Errorf("alpine entry was not decoded properly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRepositoriesFile(t *testing.T) {
|
||||||
|
rf, err := LoadRepositoriesFile(testRepositoriesFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf(testRepositoriesFile + " could not be loaded: " + err.Error())
|
||||||
|
}
|
||||||
|
expected := map[string]string{"best-charts-ever": "http://best-charts-ever.com",
|
||||||
|
"okay-charts": "http://okay-charts.org", "example123": "http://examplecharts.net/charts/123"}
|
||||||
|
|
||||||
|
numOfRepositories := len(rf.Repositories)
|
||||||
|
expectedNumOfRepositories := 3
|
||||||
|
if numOfRepositories != expectedNumOfRepositories {
|
||||||
|
t.Errorf("Expected %v repositories but only got %v", expectedNumOfRepositories, numOfRepositories)
|
||||||
|
}
|
||||||
|
|
||||||
|
for expectedRepo, expectedURL := range expected {
|
||||||
|
actual, ok := rf.Repositories[expectedRepo]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("Expected repository: %v but was not found", expectedRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedURL != actual {
|
||||||
|
t.Errorf("Expected url %s for the %s repository but got %s ", expectedURL, expectedRepo, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadChartRepository(t *testing.T) {
|
||||||
|
cr, err := LoadChartRepository(testRepository)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Problem loading chart repository from %s: %v", testRepository, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := []string{filepath.Join(testRepository, "frobnitz-1.2.3.tgz"), filepath.Join(testRepository, "sprocket-1.2.0.tgz")}
|
||||||
|
|
||||||
|
if cr.RootPath != testRepository {
|
||||||
|
t.Errorf("Expected %s as RootPath but got %s", testRepository, cr.RootPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(cr.ChartPaths, paths) {
|
||||||
|
t.Errorf("Expected %#v but got %#v\n", paths, cr.ChartPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndex(t *testing.T) {
|
||||||
|
cr, err := LoadChartRepository(testRepository)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Problem loading chart repository from %s: %v", testRepository, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cr.Index()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Error performing index: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tempIndexPath := filepath.Join(testRepository, indexPath)
|
||||||
|
actual, err := LoadIndexFile(tempIndexPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Error loading index file %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
numEntries := len(actual.Entries)
|
||||||
|
if numEntries != 2 {
|
||||||
|
t.Errorf("Expected 2 charts to be listed in index file but got %v", numEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Remove(tempIndexPath) // clean up
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,26 @@
|
||||||
nginx-0.1.0:
|
nginx-0.1.0:
|
||||||
url: http://storage.googleapis.com/kubernetes-charts/nginx-0.1.0.tgz
|
url: http://storage.googleapis.com/kubernetes-charts/nginx-0.1.0.tgz
|
||||||
name: nginx
|
name: nginx
|
||||||
description: string
|
chartfile:
|
||||||
version: 0.1.0
|
name: nginx
|
||||||
home: https://github.com/something
|
description: string
|
||||||
keywords:
|
version: 0.1.0
|
||||||
- popular
|
home: https://github.com/something
|
||||||
- web server
|
keywords:
|
||||||
- proxy
|
- popular
|
||||||
|
- web server
|
||||||
|
- proxy
|
||||||
alpine-1.0.0:
|
alpine-1.0.0:
|
||||||
url: http://storage.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz
|
url: http://storage.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz
|
||||||
name: alpine
|
name: alpine
|
||||||
description: string
|
chartfile:
|
||||||
version: 1.0.0
|
name: alpine
|
||||||
home: https://github.com/something
|
description: string
|
||||||
keywords:
|
version: 1.0.0
|
||||||
- linux
|
home: https://github.com/something
|
||||||
- alpine
|
keywords:
|
||||||
- small
|
- linux
|
||||||
- sumtin
|
- alpine
|
||||||
|
- small
|
||||||
|
- sumtin
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
best-charts-ever: http://best-charts-ever.com
|
||||||
|
okay-charts: http://okay-charts.org
|
||||||
|
example123: http://examplecharts.net/charts/123
|
||||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue