diff --git a/docs/mock/registry.go b/docs/mock/registry.go new file mode 100644 index 000000000..aad45e397 --- /dev/null +++ b/docs/mock/registry.go @@ -0,0 +1,42 @@ +package mock + +import ( + "github.com/docker/orca" + "net/http" + "net/url" +) + +type ( + MockRegistry struct { + orca.RegistryConfig + client *orca.RegistryClient + } +) + +func NewRegistry(reg *orca.RegistryConfig) (orca.Registry, error) { + u, err := url.Parse(reg.URL) + if err != nil { + return nil, err + } + + rClient := &orca.RegistryClient{ + URL: u, + } + + return &MockRegistry{ + RegistryConfig: *reg, + client: rClient, + }, nil +} + +func (r *MockRegistry) GetAuthToken(username, accessType, hostname, reponame string) (string, error) { + return "foo", nil +} + +func (r *MockRegistry) GetConfig() *orca.RegistryConfig { + return &r.RegistryConfig +} + +func (r *MockRegistry) GetTransport() http.RoundTripper { + return r.client.HttpClient.Transport +} diff --git a/docs/readme.md b/docs/readme.md new file mode 100644 index 000000000..668ebf786 --- /dev/null +++ b/docs/readme.md @@ -0,0 +1,18 @@ +# Docker Registry Go lib +This is a simple Go package to use with the Docker Registry v1. + +# Example + +``` +import registry "github.com/ehazlett/orca/registry/v1" + +// make sure to handle the err +client, _ := registry.NewRegistryClient("http://localhost:5000", nil) + +res, _ := client.Search("busybox", 1, 100) + +fmt.Printf("Number of Repositories: %d\n", res.NumberOfResults) +for _, r := range res.Results { + fmt.Printf(" - Name: %s\n", r.Name) +} +``` diff --git a/docs/v1/error.go b/docs/v1/error.go new file mode 100644 index 000000000..769671a8b --- /dev/null +++ b/docs/v1/error.go @@ -0,0 +1,15 @@ +package v1 + +import ( + "fmt" +) + +type Error struct { + StatusCode int + Status string + msg string +} + +func (e Error) Error() string { + return fmt.Sprintf("%s: %s", e.Status, e.msg) +} diff --git a/docs/v1/registry.go b/docs/v1/registry.go new file mode 100644 index 000000000..103faea67 --- /dev/null +++ b/docs/v1/registry.go @@ -0,0 +1,277 @@ +package v1 + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/url" + "path" + "strings" + "time" + + log "github.com/Sirupsen/logrus" +) + +var ( + ErrNotFound = errors.New("Not found") + defaultHTTPTimeout = 30 * time.Second +) + +type RegistryClient struct { + URL *url.URL + tlsConfig *tls.Config + httpClient *http.Client +} + +type Repo struct { + Namespace string + Repository string +} + +func parseRepo(repo string) Repo { + namespace := "library" + r := repo + + if strings.Index(repo, "/") != -1 { + parts := strings.Split(repo, "/") + namespace = parts[0] + r = path.Join(parts[1:]...) + } + + return Repo{ + Namespace: namespace, + Repository: r, + } +} + +func newHTTPClient(u *url.URL, tlsConfig *tls.Config, timeout time.Duration) *http.Client { + httpTransport := &http.Transport{ + TLSClientConfig: tlsConfig, + } + + httpTransport.Dial = func(proto, addr string) (net.Conn, error) { + return net.DialTimeout(proto, addr, timeout) + } + return &http.Client{Transport: httpTransport} +} + +func NewRegistryClient(registryUrl string, tlsConfig *tls.Config) (*RegistryClient, error) { + u, err := url.Parse(registryUrl) + if err != nil { + return nil, err + } + httpClient := newHTTPClient(u, tlsConfig, defaultHTTPTimeout) + return &RegistryClient{ + URL: u, + httpClient: httpClient, + tlsConfig: tlsConfig, + }, nil +} + +func (client *RegistryClient) doRequest(method string, path string, body []byte, headers map[string]string) ([]byte, error) { + b := bytes.NewBuffer(body) + + req, err := http.NewRequest(method, client.URL.String()+"/v1"+path, b) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + if headers != nil { + for header, value := range headers { + req.Header.Add(header, value) + } + } + + resp, err := client.httpClient.Do(req) + if err != nil { + if !strings.Contains(err.Error(), "connection refused") && client.tlsConfig == nil { + return nil, fmt.Errorf("%v. Are you trying to connect to a TLS-enabled endpoint without TLS?", err) + } + return nil, err + } + + defer resp.Body.Close() + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode == 404 { + return nil, ErrNotFound + } + + if resp.StatusCode >= 400 { + return nil, Error{StatusCode: resp.StatusCode, Status: resp.Status, msg: string(data)} + } + + return data, nil +} + +func (client *RegistryClient) Search(query string, page int, numResults int) (*SearchResult, error) { + if numResults < 1 { + numResults = 100 + } + uri := fmt.Sprintf("/search?q=%s&n=%d&page=%d", query, numResults, page) + data, err := client.doRequest("GET", uri, nil, nil) + if err != nil { + return nil, err + } + + res := &SearchResult{} + if err := json.Unmarshal(data, &res); err != nil { + return nil, err + } + + return res, nil +} + +func (client *RegistryClient) DeleteRepository(repo string) error { + r := parseRepo(repo) + uri := fmt.Sprintf("/repositories/%s/%s/", r.Namespace, r.Repository) + if _, err := client.doRequest("DELETE", uri, nil, nil); err != nil { + return err + } + + return nil +} + +func (client *RegistryClient) DeleteTag(repo string, tag string) error { + r := parseRepo(repo) + uri := fmt.Sprintf("/repositories/%s/%s/tags/%s", r.Namespace, r.Repository, tag) + if _, err := client.doRequest("DELETE", uri, nil, nil); err != nil { + return err + } + + return nil +} + +func (client *RegistryClient) Layer(id string) (*Layer, error) { + uri := fmt.Sprintf("/images/%s/json", id) + data, err := client.doRequest("GET", uri, nil, nil) + if err != nil { + return nil, err + } + + layer := &Layer{} + if err := json.Unmarshal(data, &layer); err != nil { + return nil, err + } + + return layer, nil +} + +func (client *RegistryClient) loadLayer(name, id string) ([]Layer, []Tag, int64, error) { + uri := fmt.Sprintf("/images/%s/json", id) + layer := Layer{} + layers := []Layer{} + tags := []Tag{} + size := int64(0) + + data, err := client.doRequest("GET", uri, nil, nil) + if err != nil { + return nil, nil, -1, err + } + + if err := json.Unmarshal(data, &layer); err != nil { + return nil, nil, -1, err + } + + uri = fmt.Sprintf("/images/%s/ancestry", id) + + ancestry := []string{} + + data, err = client.doRequest("GET", uri, nil, nil) + if err != nil { + return nil, nil, -1, err + } + + if err = json.Unmarshal(data, &ancestry); err != nil { + return nil, nil, -1, err + } + + tag := Tag{ + ID: id, + Name: name, + } + + tags = append(tags, tag) + layer.Ancestry = ancestry + + layers = append(layers, layer) + // parse ancestor layers + for _, i := range ancestry { + uri = fmt.Sprintf("/images/%s/json", i) + l := &Layer{} + + data, err = client.doRequest("GET", uri, nil, nil) + if err != nil { + return nil, nil, -1, err + } + + if err = json.Unmarshal(data, &l); err != nil { + return nil, nil, -1, err + } + size += l.Size + layers = append(layers, *l) + } + + return layers, tags, size, nil +} + +func (client *RegistryClient) Repository(name string) (*Repository, error) { + r := parseRepo(name) + uri := fmt.Sprintf("/repositories/%s/%s/tags", r.Namespace, r.Repository) + + repository := &Repository{ + Name: path.Join(r.Namespace, r.Repository), + Namespace: r.Namespace, + Repository: r.Repository, + } + + // HACK: check for hub url and return + // used in orca catalog + baseURL := client.URL.String() + if strings.Contains(baseURL, "index.docker.io") { + return repository, nil + } + + var repoTags map[string]string + + data, err := client.doRequest("GET", uri, nil, nil) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(data, &repoTags); err != nil { + return nil, err + } + + layers := []Layer{} + tags := []Tag{} + size := int64(0) + + for n, id := range repoTags { + l, t, s, err := client.loadLayer(n, id) + if err != nil { + log.Warnf("error loading layer: id=%s", id) + continue + } + + layers = append(layers, l...) + tags = append(tags, t...) + size += s + } + + repository.Tags = tags + repository.Layers = layers + repository.Size = int64(size) / int64(len(tags)) + + return repository, nil +} diff --git a/docs/v1/repository.go b/docs/v1/repository.go new file mode 100644 index 000000000..6f6ca4316 --- /dev/null +++ b/docs/v1/repository.go @@ -0,0 +1,47 @@ +package v1 + +import ( + "time" + + "github.com/docker/engine-api/types" +) + +type ( + Tag struct { + ID string + Name string + } + + ContainerConfig struct { + types.ContainerJSON + Cmd []string `json:"Cmd,omitempty"` + } + + Layer struct { + ID string `json:"id,omitempty"` + Parent string `json:"parent,omitempty"` + Created *time.Time `json:"created,omitempty"` + Container string `json:"container,omitempty"` + ContainerConfig *ContainerConfig `json:"container_config,omitempty"` + DockerVersion string `json:"docker_version,omitempty"` + Author string `json:"author,omitempty"` + Architecture string `json:"architecture,omitempty"` + OS string `json:"os,omitempty"` + Size int64 `json:"size,omitempty"` + Ancestry []string `json:"ancestry,omitempty"` + } + + Repository struct { + Description string `json:"description,omitempty"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + Repository string `json:"repository,omitempty"` + Tags []Tag `json:"tags,omitempty"` + Layers []Layer `json:"layers,omitempty"` + Size int64 `json:"size,omitempty"` + // these are only for the official index + Trusted bool `json:"is_trusted,omitempty"` + Official bool `json:"is_official,omitempty"` + StarCount int `json:"star_count,omitempty"` + } +) diff --git a/docs/v1/search.go b/docs/v1/search.go new file mode 100644 index 000000000..084f8f98e --- /dev/null +++ b/docs/v1/search.go @@ -0,0 +1,10 @@ +package v1 + +type ( + SearchResult struct { + NumberOfResults int `json:"num_results,omitempty"` + NumberOfPages int `json:"num_pages,omitempty"` + Query string `json:"query,omitempty"` + Results []*Repository `json:"results,omitempty"` + } +) diff --git a/docs/v2/registry.go b/docs/v2/registry.go new file mode 100644 index 000000000..2188f019e --- /dev/null +++ b/docs/v2/registry.go @@ -0,0 +1,149 @@ +package v2 + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "time" + + log "github.com/Sirupsen/logrus" + "github.com/docker/orca" + "github.com/docker/orca/auth" +) + +var ( + ErrNotFound = errors.New("Not found") + defaultHTTPTimeout = 30 * time.Second +) + +type ( + AuthToken struct { + Token string `json:"token"` + } + + V2Registry struct { + orca.RegistryConfig + client *orca.RegistryClient + } +) + +func NewRegistry(reg *orca.RegistryConfig, swarmTLSConfig *tls.Config) (orca.Registry, error) { + // sanity check the registry settings + u, err := url.Parse(reg.URL) + if err != nil { + return nil, fmt.Errorf("The provided Docker Trusted Registry URL was malformed and could not be parsed") + } + + // Create a new TLS config for the registry, based on swarm's + // This will allow us not to mess with the Swarm RootCAs + tlsConfig := *swarmTLSConfig + tlsConfig.InsecureSkipVerify = reg.Insecure + if reg.CACert != "" { + // If the user specified a CA, create a new RootCA pool containing only that CA cert. + log.Debugf("cert: %s", reg.CACert) + certPool := x509.NewCertPool() + certPool.AppendCertsFromPEM([]byte(reg.CACert)) + tlsConfig.RootCAs = certPool + log.Debug("Connecting to Registry with user-provided CA") + } else { + // If the user did not specify a CA, fall back to the system's Root CAs + tlsConfig.RootCAs = nil + log.Debug("Connecting to Registry with system Root CAs") + } + + httpClient := &http.Client{ + Transport: &http.Transport{TLSClientConfig: &tlsConfig}, + Timeout: defaultHTTPTimeout, + } + + rClient := &orca.RegistryClient{ + URL: u, + HttpClient: httpClient, + } + + return &V2Registry{ + RegistryConfig: *reg, + client: rClient, + }, nil +} + +func (r *V2Registry) doRequest(method string, path string, body []byte, headers map[string]string, username string) ([]byte, error) { + b := bytes.NewBuffer(body) + + req, err := http.NewRequest(method, path, b) + if err != nil { + log.Errorf("couldn't create request: %s", err) + return nil, err + } + + // The DTR Auth server will validate the UCP client cert and will grant access to whatever + // username is passed to it. + // However, DTR 1.4.3 rejects empty password strings under LDAP, in order to disallow anonymous users. + req.SetBasicAuth(username, "really?") + + if headers != nil { + for header, value := range headers { + req.Header.Add(header, value) + } + } + + resp, err := r.client.HttpClient.Do(req) + if err != nil { + if err == http.ErrHandlerTimeout { + log.Error("Login timed out to Docker Trusted Registry") + return nil, err + } + log.Errorf("There was an error while authenticating: %s", err) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 401 { + // Unauthorized + log.Warnf("Unauthorized") + return nil, auth.ErrUnauthorized + } else if resp.StatusCode >= 400 { + log.Errorf("Docker Trusted Registry returned an unexpected status code while authenticating: %s", resp.Status) + return nil, auth.ErrUnknown + } + + rBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Errorf("couldn't read body: %s", err) + return nil, err + } + + return rBody, nil +} + +func (r *V2Registry) GetAuthToken(username, accessType, hostname, reponame string) (string, error) { + uri := fmt.Sprintf("%s/auth/token?scope=repository:%s:%s&service=%s", r.RegistryConfig.URL, reponame, accessType, hostname) + + log.Debugf("contacting DTR for auth token: %s", uri) + + data, err := r.doRequest("GET", uri, nil, nil, username) + if err != nil { + return "", err + } + + var token AuthToken + if err := json.Unmarshal(data, &token); err != nil { + return "", err + } + + return token.Token, nil +} + +func (r *V2Registry) GetConfig() *orca.RegistryConfig { + return &r.RegistryConfig +} + +func (r *V2Registry) GetTransport() http.RoundTripper { + return r.client.HttpClient.Transport +}