Initial commit

parents
BIN := "container-inventory"
all: bin/$(BIN)
bin:
mkdir -p bin
bin/$(BIN): bin $(shell find . -name '*.go') go.mod go.sum
cd cmd/$(BIN) && go build -o ../../bin/$(BIN)
clean:
rm -rf bin
install: bin/$(BIN)
cp bin/$(BIN) $(GOPATH)/bin/
test:
go test -v ./...
.PHONY: clean
.PHONY: all
.PHONY: install
.PHONY: test
# container-inventory
container-inventory is a little tool inspired by httpie that should make
interacting with Docker registries through the command-line easier.
## Usage
## Commands
### sessions
### repositories
### tags
package cmd
import (
"context"
"fmt"
"net/url"
"github.com/spf13/cobra"
"gitlab.com/zerok/container-inventory/pkg/registryclient"
)
var reposCmd = &cobra.Command{
Use: "repositories",
Aliases: []string{"rs"},
Short: `Repository commands`,
Run: func(cmd *cobra.Command, args []string) {
showSubcommandsList(cmd)
},
}
var reposListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List all repositories",
Run: func(cmd *cobra.Command, args []string) {
var err error
ctx := logger.WithContext(context.Background())
rc := registryclient.NewClient(registryclient.WithHost(registry))
if err := activateSession(rc, sessionName); err != nil {
logger.Fatal().Err(err).Msgf("Failed to load session %s for %s", sessionName, registry)
}
repos, err := rc.ListRepositories(ctx)
if err != nil {
if err == registryclient.ErrUnauthorized {
logger.Fatal().Msg("You have to be authenticated for this operation. Please create a new session using the `session new` command.")
}
logger.Fatal().Err(err).Msg("Failed to retrieve repository list")
}
for _, r := range repos {
fmt.Println(r)
}
},
}
type Repo struct {
Registry string
Name string
}
func parseRepository(rep string) (*Repo, error) {
u, err := url.Parse("https://" + rep)
if err != nil {
return nil, err
}
return &Repo{
Registry: u.Host,
Name: u.Path,
}, nil
}
var reposCopyCmd = &cobra.Command{
Use: "copy",
Aliases: []string{"cp"},
Run: func(cmd *cobra.Command, args []string) {
ctx := logger.WithContext(context.Background())
if len(args) < 2 {
logger.Fatal().Msg("You have to specify at least one source and one target repository")
}
src := args[0]
targets := args[1:]
srcRepo, err := parseRepository(src)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to parse source repository")
}
for _, t := range targets {
if _, err := parseRepository(t); err != nil {
logger.Fatal().Err(err).Msgf("Failed to parse target repository '%s'", t)
}
}
srcReg := registryclient.NewClient(registryclient.WithHost(srcRepo.Registry))
srcReg.ListTags(ctx, srcRepo.Name)
},
}
func init() {
reposCmd.AddCommand(reposListCmd)
reposCmd.AddCommand(reposCopyCmd)
rootCmd.AddCommand(reposCmd)
}
package cmd
import (
"fmt"
"os"
"os/user"
"path/filepath"
"github.com/rs/zerolog"
"github.com/spf13/cobra"
"gitlab.com/zerok/container-inventory/pkg/sessionfile"
)
var logger zerolog.Logger
var registry string
var repository string
var verbose bool
var sessions *sessionfile.Manager
func showSubcommandsList(cmd *cobra.Command) {
fmt.Println("Please use one of the following sub-commands:")
for _, c := range cmd.Commands() {
fmt.Printf(" %s\n", c.Use)
}
}
var rootCmd = &cobra.Command{
Use: "cinv",
Run: func(cmd *cobra.Command, args []string) {
showSubcommandsList(cmd)
},
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if verbose {
logger = logger.Level(zerolog.DebugLevel)
} else {
logger = logger.Level(zerolog.InfoLevel)
}
u, err := user.Current()
if err != nil {
logger.Fatal().Err(err).Msg("Failed to determine the current user")
}
sessions = sessionfile.NewManager(filepath.Join(u.HomeDir, ".container-inventory", "sessions"))
},
}
func init() {
logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Logger()
rootCmd.PersistentFlags().StringVarP(&registry, "registry", "r", "", "Registry host")
rootCmd.PersistentFlags().StringVarP(&repository, "repository", "i", "", "Repository name")
rootCmd.PersistentFlags().StringVarP(&sessionName, "session", "s", "", "Session name")
rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Verbose logging")
}
func MustExecute() {
if err := rootCmd.Execute(); err != nil {
logger.Fatal().Err(err).Msg("cinv failed")
}
}
package cmd
import (
"github.com/bgentry/speakeasy"
"github.com/spf13/cobra"
"gitlab.com/zerok/container-inventory/pkg/registryclient"
)
var sessionName string
var username string
var password string
func assertRegistry() {
if registry == "" {
logger.Fatal().Msg("Please specify a registry (--registry NAME)")
}
}
func assertSessionName() {
if sessionName == "" {
logger.Fatal().Msg("Please specify a session name (--session NAME)")
}
}
func assertUsername() {
if sessionName == "" {
logger.Fatal().Msg("Please specify a username (--username NAME)")
}
}
func activateSession(rc *registryclient.Client, name string) error {
if name == "" {
if ok, _ := sessions.Exists(rc.Host, "default"); !ok {
return nil
}
name = "default"
}
logger.Info().Msgf("Session: %s", name)
s, err := sessions.GetSession(rc.Host, name)
if err != nil {
return err
}
rc.SetSession(s)
return nil
}
var sessionsCmd = &cobra.Command{
Use: "sessions",
Run: func(cmd *cobra.Command, args []string) {
},
}
var sessionsCreateCmd = &cobra.Command{
Use: "create",
Aliases: []string{"new"},
Run: func(cmd *cobra.Command, args []string) {
assertRegistry()
assertSessionName()
assertUsername()
password, err := speakeasy.Ask("Password: ")
if err != nil {
logger.Fatal().Err(err).Msg("Failed to read the password")
}
if _, err := sessions.CreateSession(sessionName, registry, username, password); err != nil {
logger.Fatal().Err(err).Msgf("Failed to create session %s", sessionName)
}
logger.Info().Msgf("Created session %s for %s", sessionName, registry)
},
}
func init() {
sessionsCreateCmd.Flags().StringVar(&username, "username", "", "Username in the session")
sessionsCmd.AddCommand(sessionsCreateCmd)
rootCmd.AddCommand(sessionsCmd)
}
package cmd
import (
"context"
"fmt"
"github.com/spf13/cobra"
"gitlab.com/zerok/container-inventory/pkg/registryclient"
)
var withDigest bool
var tagsCmd = &cobra.Command{
Use: "tags",
Run: func(cmd *cobra.Command, args []string) {
showSubcommandsList(cmd)
},
}
var listTagsCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
if repository == "" {
logger.Fatal().Msg("Please specify a repository inside the registry")
}
c := registryclient.NewClient(registryclient.WithHost(registry))
if err := activateSession(c, sessionName); err != nil {
logger.Fatal().Err(err).Msgf("Failed to load session %s for %s", sessionName, registry)
}
tags, err := c.ListTags(ctx, repository)
if err != nil {
logger.Fatal().Err(err).Msgf("Failed to retrieve tags for %s", repository)
}
for _, tag := range tags {
if withDigest {
m, err := c.GetManifest(ctx, repository, tag)
if err != nil {
logger.Fatal().Err(err).Msgf("Failed to retrieve manifest for %s", tag)
}
fmt.Printf("%s (%s)\n", tag, m.Digest)
} else {
fmt.Println(tag)
}
}
},
}
func init() {
listTagsCmd.Flags().BoolVar(&withDigest, "with-digest", false, "Include the content digest")
tagsCmd.AddCommand(listTagsCmd)
rootCmd.AddCommand(tagsCmd)
}
package main
import "gitlab.com/zerok/container-inventory/cmd/container-inventory/cmd"
func main() {
cmd.MustExecute()
}
package registryclient
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/pkg/errors"
"gitlab.com/zerok/container-inventory/pkg/sessionfile"
)
var ErrUnauthorized = errors.New("unauthorized")
var ErrForbidden = errors.New("forbidden")
type Client struct {
Host string
session *sessionfile.Session
}
type ClientOption func(c *Client)
func WithHost(host string) ClientOption {
return func(c *Client) {
c.Host = host
}
}
type listRepositoriesResponse struct {
Repositories []string `json:"repositories"`
}
type listTagsResponse struct {
Tags []string `json:"tags"`
}
type Manifest struct {
Name string `json:"name"`
Tag string `json:"tag"`
Digest string
}
func NewClient(options ...ClientOption) *Client {
c := &Client{}
for _, opt := range options {
opt(c)
}
return c
}
func (c *Client) SetSession(s *sessionfile.Session) {
c.session = s
}
func (c *Client) injectSession(req *http.Request) {
if c.session != nil {
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", c.session.Auth.RawAuth))
}
}
func (c *Client) buildURL(path string, args ...interface{}) string {
p := fmt.Sprintf(path, args...)
return fmt.Sprintf("https://%s%s", c.Host, p)
}
func (c *Client) ListRepositories(ctx context.Context) ([]string, error) {
hc := http.Client{}
req, _ := http.NewRequest(http.MethodGet, c.buildURL("/v2/_catalog"), nil)
c.injectSession(req)
resp, err := hc.Do(req.WithContext(ctx))
if err != nil {
return nil, errors.Wrap(err, "failed to fetch repositories")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusForbidden:
return nil, ErrForbidden
case http.StatusUnauthorized:
return nil, ErrUnauthorized
default:
return nil, errors.Errorf("Unexpected status code: %d", resp.StatusCode)
}
}
var lr listRepositoriesResponse
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil {
return nil, errors.Wrap(err, "failed to decode repositories response")
}
return lr.Repositories, nil
}
func (c *Client) GetManifest(ctx context.Context, image string, ref string) (*Manifest, error) {
hc := http.Client{}
req, _ := http.NewRequest(http.MethodGet, c.buildURL("/v2/%s/manifests/%s", url.QueryEscape(image), url.QueryEscape(ref)), nil)
c.injectSession(req)
resp, err := hc.Do(req.WithContext(ctx))
if err != nil {
return nil, errors.Wrap(err, "failed to fetch repositories")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusForbidden:
return nil, ErrForbidden
case http.StatusUnauthorized:
return nil, ErrUnauthorized
default:
return nil, errors.Errorf("Unexpected status code: %d", resp.StatusCode)
}
}
var mr Manifest
if err := json.NewDecoder(resp.Body).Decode(&mr); err != nil {
return nil, errors.Wrap(err, "failed to decode repositories response")
}
mr.Digest = resp.Header.Get("Docker-Content-Digest")
return &mr, nil
}
func (c *Client) ListTags(ctx context.Context, image string) ([]string, error) {
hc := http.Client{}
req, _ := http.NewRequest(http.MethodGet, c.buildURL("/v2/%s/tags/list", url.QueryEscape(image)), nil)
c.injectSession(req)
resp, err := hc.Do(req.WithContext(ctx))
if err != nil {
return nil, errors.Wrap(err, "failed to fetch repositories")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusForbidden:
return nil, ErrForbidden
case http.StatusUnauthorized:
return nil, ErrUnauthorized
default:
return nil, errors.Errorf("Unexpected status code: %d", resp.StatusCode)
}
}
var lr listTagsResponse
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil {
return nil, errors.Wrap(err, "failed to decode repositories response")
}
return lr.Tags, nil
}
package sessionfile
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/pkg/errors"
)
// Manager handles multiple sessions grouped by hosts.
type Manager struct {
root string
}
// NewManager creates a new manager instance using the given path as data store.
func NewManager(path string) *Manager {
return &Manager{root: path}
}
// GetSession retrieves a session object from the persistent data store.
func (m *Manager) GetSession(host, name string) (*Session, error) {
path := filepath.Join(m.root, host, fmt.Sprintf("%s.json", name))
fp, err := os.Open(path)
if err != nil {
return nil, errors.Wrapf(err, "failed to open session file %s", path)
}
defer fp.Close()
return Parse(host, fp)
}
// Exists checks if a session of the given name exists for the provided host.
func (m *Manager) Exists(host, name string) (bool, error) {
path := filepath.Join(m.root, host, fmt.Sprintf("%s.json", name))
st, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
if st.IsDir() {
return false, errors.Wrapf(err, "%s is a directory not a file", path)
}
return true, nil
}
// CreateSession creates a new session and persists it inside the manager.
func (m *Manager) CreateSession(name, host, username, password string) (*Session, error) {
hostFolder := filepath.Join(m.root, host)
path := filepath.Join(hostFolder, fmt.Sprintf("%s.json", name))
session := NewSession(host, username, password)
if err := os.MkdirAll(hostFolder, 0700); err != nil {
return nil, errors.Wrapf(err, "failed to create host folder %s", hostFolder)
}
fp, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
defer fp.Close()
if err != nil {
return nil, errors.Wrapf(err, "failed to open session file for writing: %s", path)
}
return session, json.NewEncoder(fp).Encode(session)
}
package sessionfile
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"github.com/pkg/errors"
)
// SessionAuth represents the actual auth information of a session.
type SessionAuth struct {
RawAuth string `json:"raw_auth"`
Type string `json:"type"`
}
// Session typically abstracts some kind of credential-set for a given scope
// (i.e. host).
type Session struct {
Scope string `json:"-"`
Auth SessionAuth `json:"auth"`
}
// NewSession creates a new session for the given host, username, and password.
func NewSession(host, username, password string) *Session {
a := fmt.Sprintf("%s:%s", username, password)
s := &Session{
Scope: host,
Auth: SessionAuth{
Type: "basic",
RawAuth: base64.StdEncoding.EncodeToString([]byte(a)),
},