diff --git a/.gitignore b/.gitignore index 45bd181cb..d3397d696 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /bin/ **/coverage.txt **/coverage.out +containerd.test diff --git a/client.go b/client.go new file mode 100644 index 000000000..5f049af60 --- /dev/null +++ b/client.go @@ -0,0 +1,61 @@ +package containerd + +import ( + "context" + "io/ioutil" + "log" + "time" + + "github.com/containerd/containerd/api/services/containers" + "github.com/pkg/errors" + "google.golang.org/grpc" + "google.golang.org/grpc/grpclog" +) + +// New returns a new containerd client that is connected to the containerd +// instance provided by address +func New(address string) (*Client, error) { + // reset the grpc logger so that it does not output in the STDIO of the calling process + grpclog.SetLogger(log.New(ioutil.Discard, "", log.LstdFlags)) + + opts := []grpc.DialOption{ + grpc.WithInsecure(), + grpc.WithTimeout(100 * time.Second), + grpc.WithDialer(dialer), + } + conn, err := grpc.Dial(dialAddress(address), opts...) + if err != nil { + return nil, errors.Wrapf(err, "failed to dial %q", address) + } + return &Client{ + conn: conn, + }, nil +} + +// Client is the client to interact with containerd and its various services +// using a uniform interface +type Client struct { + conn *grpc.ClientConn +} + +// Containers returns all containers created in containerd +func (c *Client) Containers(ctx context.Context) ([]*Container, error) { + r, err := c.containers().List(ctx, &containers.ListContainersRequest{}) + if err != nil { + return nil, err + } + var out []*Container + for _, container := range r.Containers { + out = append(out, containerFromProto(c, container)) + } + return out, nil +} + +// Close closes the clients connection to containerd +func (c *Client) Close() error { + return c.conn.Close() +} + +func (c *Client) containers() containers.ContainersClient { + return containers.NewContainersClient(c.conn) +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 000000000..12e29df59 --- /dev/null +++ b/client_test.go @@ -0,0 +1,18 @@ +package containerd + +import "testing" + +const defaultAddress = "/run/containerd/containerd.sock" + +func TestNewClient(t *testing.T) { + client, err := New(defaultAddress) + if err != nil { + t.Fatal(err) + } + if client == nil { + t.Fatal("New() returned nil client") + } + if err := client.Close(); err != nil { + t.Errorf("client closed returned errror %v", err) + } +} diff --git a/client_unix.go b/client_unix.go new file mode 100644 index 000000000..b2ba75270 --- /dev/null +++ b/client_unix.go @@ -0,0 +1,17 @@ +package containerd + +import ( + "fmt" + "net" + "strings" + "time" +) + +func dialer(address string, timeout time.Duration) (net.Conn, error) { + address = strings.TrimPrefix(address, "unix://") + return net.DialTimeout("unix", address, timeout) +} + +func dialAddress(address string) string { + return fmt.Sprintf("unix://%s", address) +} diff --git a/client_windows.go b/client_windows.go new file mode 100644 index 000000000..7de5677a0 --- /dev/null +++ b/client_windows.go @@ -0,0 +1,16 @@ +package containerd + +import ( + "net" + "time" + + winio "github.com/Microsoft/go-winio" +) + +func dialer(address string, timeout time.Duration) (net.Conn, error) { + return winio.DialPipe(bindAddress, &timeout) +} + +func dialAddress(address string) string { + return address +} diff --git a/container.go b/container.go new file mode 100644 index 000000000..d47b0615e --- /dev/null +++ b/container.go @@ -0,0 +1,21 @@ +package containerd + +import "github.com/containerd/containerd/api/services/containers" + +func containerFromProto(client *Client, c containers.Container) *Container { + return &Container{ + client: client, + id: c.ID, + } +} + +type Container struct { + client *Client + + id string +} + +// ID returns the container's unique id +func (c *Container) ID() string { + return c.id +} diff --git a/container_test.go b/container_test.go new file mode 100644 index 000000000..da1a9818b --- /dev/null +++ b/container_test.go @@ -0,0 +1,23 @@ +package containerd + +import ( + "context" + "testing" +) + +func TestContainerList(t *testing.T) { + client, err := New(defaultAddress) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + containers, err := client.Containers(context.Background()) + if err != nil { + t.Errorf("container list returned error %v", err) + return + } + if len(containers) != 0 { + t.Errorf("expected 0 containers but received %d", len(containers)) + } +} diff --git a/spec.go b/spec.go new file mode 100644 index 000000000..5913d29b6 --- /dev/null +++ b/spec.go @@ -0,0 +1,44 @@ +package containerd + +import specs "github.com/opencontainers/runtime-spec/specs-go" + +type SpecOpts func(s *specs.Spec) error + +func WithImageRef(ref string) SpecOpts { + return func(s *specs.Spec) error { + if s.Annotations == nil { + s.Annotations = make(map[string]string) + } + s.Annotations["image"] = ref + return nil + } +} + +func WithHostname(id string) SpecOpts { + return func(s *specs.Spec) error { + s.Hostname = id + return nil + } +} + +func WithArgs(args ...string) SpecOpts { + return func(s *specs.Spec) error { + s.Process.Args = args + return nil + } +} + +// GenerateSpec will generate a default spec from the provided image +// for use as a containerd container +func GenerateSpec(opts ...SpecOpts) (*specs.Spec, error) { + s, err := createDefaultSpec() + if err != nil { + return nil, err + } + for _, o := range opts { + if err := o(s); err != nil { + return nil, err + } + } + return s, nil +} diff --git a/spec_unix.go b/spec_unix.go new file mode 100644 index 000000000..b994125bf --- /dev/null +++ b/spec_unix.go @@ -0,0 +1,225 @@ +package containerd + +import ( + "fmt" + "runtime" + "strconv" + "strings" + + "github.com/opencontainers/image-spec/specs-go/v1" + specs "github.com/opencontainers/runtime-spec/specs-go" +) + +const ( + rwm = "rwm" + defaultRootfsPath = "rootfs" +) + +func defaltCaps() []string { + return []string{ + "CAP_CHOWN", + "CAP_DAC_OVERRIDE", + "CAP_FSETID", + "CAP_FOWNER", + "CAP_MKNOD", + "CAP_NET_RAW", + "CAP_SETGID", + "CAP_SETUID", + "CAP_SETFCAP", + "CAP_SETPCAP", + "CAP_NET_BIND_SERVICE", + "CAP_SYS_CHROOT", + "CAP_KILL", + "CAP_AUDIT_WRITE", + } +} + +func defaultNamespaces() []specs.LinuxNamespace { + return []specs.LinuxNamespace{ + { + Type: specs.PIDNamespace, + }, + { + Type: specs.IPCNamespace, + }, + { + Type: specs.UTSNamespace, + }, + { + Type: specs.MountNamespace, + }, + { + Type: specs.NetworkNamespace, + }, + } +} + +func createDefaultSpec() (*specs.Spec, error) { + s := &specs.Spec{ + Version: specs.Version, + Platform: specs.Platform{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + }, + Root: specs.Root{ + Path: defaultRootfsPath, + }, + Process: specs.Process{ + Cwd: "/", + NoNewPrivileges: true, + User: specs.User{ + UID: 0, + GID: 0, + }, + Capabilities: &specs.LinuxCapabilities{ + Bounding: defaltCaps(), + Permitted: defaltCaps(), + Inheritable: defaltCaps(), + Effective: defaltCaps(), + Ambient: defaltCaps(), + }, + Rlimits: []specs.LinuxRlimit{ + { + Type: "RLIMIT_NOFILE", + Hard: uint64(1024), + Soft: uint64(1024), + }, + }, + }, + Mounts: []specs.Mount{ + { + Destination: "/proc", + Type: "proc", + Source: "proc", + }, + { + Destination: "/dev", + Type: "tmpfs", + Source: "tmpfs", + Options: []string{"nosuid", "strictatime", "mode=755", "size=65536k"}, + }, + { + Destination: "/dev/pts", + Type: "devpts", + Source: "devpts", + Options: []string{"nosuid", "noexec", "newinstance", "ptmxmode=0666", "mode=0620", "gid=5"}, + }, + { + Destination: "/dev/shm", + Type: "tmpfs", + Source: "shm", + Options: []string{"nosuid", "noexec", "nodev", "mode=1777", "size=65536k"}, + }, + { + Destination: "/dev/mqueue", + Type: "mqueue", + Source: "mqueue", + Options: []string{"nosuid", "noexec", "nodev"}, + }, + { + Destination: "/sys", + Type: "sysfs", + Source: "sysfs", + Options: []string{"nosuid", "noexec", "nodev", "ro"}, + }, + { + Destination: "/run", + Type: "tmpfs", + Source: "tmpfs", + Options: []string{"nosuid", "strictatime", "mode=755", "size=65536k"}, + }, + { + Destination: "/etc/resolv.conf", + Type: "bind", + Source: "/etc/resolv.conf", + Options: []string{"rbind", "ro"}, + }, + { + Destination: "/etc/hosts", + Type: "bind", + Source: "/etc/hosts", + Options: []string{"rbind", "ro"}, + }, + { + Destination: "/etc/localtime", + Type: "bind", + Source: "/etc/localtime", + Options: []string{"rbind", "ro"}, + }, + }, + Linux: &specs.Linux{ + Resources: &specs.LinuxResources{ + Devices: []specs.LinuxDeviceCgroup{ + { + Allow: false, + Access: rwm, + }, + }, + }, + Namespaces: defaultNamespaces(), + }, + } + return s, nil +} + +func WithTTY(s *specs.Spec) error { + s.Process.Terminal = true + s.Process.Env = append(s.Process.Env, "TERM=xterm") + return nil +} + +func WithHostNamespace(ns specs.LinuxNamespaceType) SpecOpts { + return func(s *specs.Spec) error { + for i, n := range s.Linux.Namespaces { + if n.Type == ns { + s.Linux.Namespaces = append(s.Linux.Namespaces[:i], s.Linux.Namespaces[i+1:]...) + return nil + } + } + return nil + } +} + +func WithImage(config *v1.ImageConfig) SpecOpts { + return func(s *specs.Spec) error { + env := []string{ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + } + s.Process.Env = append(env, config.Env...) + cmd := config.Cmd + var ( + uid, gid uint32 + ) + s.Process.Args = append(config.Entrypoint, cmd...) + if config.User != "" { + parts := strings.Split(config.User, ":") + switch len(parts) { + case 1: + v, err := strconv.ParseUint(parts[0], 0, 10) + if err != nil { + return err + } + uid, gid = uint32(v), uint32(v) + case 2: + v, err := strconv.ParseUint(parts[0], 0, 10) + if err != nil { + return err + } + uid = uint32(v) + if v, err = strconv.ParseUint(parts[1], 0, 10); err != nil { + return err + } + gid = uint32(v) + default: + return fmt.Errorf("invalid USER value %s", config.User) + } + } + s.Process.User.UID, s.Process.User.GID = uid, gid + cwd := config.WorkingDir + if cwd == "" { + cwd = "/" + } + s.Process.Cwd = cwd + return nil + } +} diff --git a/spec_unix_test.go b/spec_unix_test.go new file mode 100644 index 000000000..e98f9ae8b --- /dev/null +++ b/spec_unix_test.go @@ -0,0 +1,56 @@ +package containerd + +import "testing" + +func TestGenerateSpec(t *testing.T) { + s, err := GenerateSpec() + if err != nil { + t.Fatal(err) + } + if s == nil { + t.Fatal("GenerateSpec() returns a nil spec") + } + + // check for matching caps + defaults := defaltCaps() + for _, cl := range [][]string{ + s.Process.Capabilities.Ambient, + s.Process.Capabilities.Bounding, + s.Process.Capabilities.Permitted, + s.Process.Capabilities.Inheritable, + s.Process.Capabilities.Effective, + } { + for i := 0; i < len(defaults); i++ { + if cl[i] != defaults[i] { + t.Errorf("cap at %d does not match set %q != %q", i, defaults[i], cl[i]) + } + } + } + + // check default namespaces + defaultNS := defaultNamespaces() + for i, ns := range s.Linux.Namespaces { + if defaultNS[i] != ns { + t.Errorf("ns at %d does not match set %q != %q", i, defaultNS[i], ns) + } + } + + // test that we don't have tty set + if s.Process.Terminal { + t.Error("terminal set on default process") + } +} + +func TestSpecWithTTY(t *testing.T) { + s, err := GenerateSpec(WithTTY) + if err != nil { + t.Fatal(err) + } + if !s.Process.Terminal { + t.Error("terminal net set WithTTY()") + } + v := s.Process.Env[len(s.Process.Env)-1] + if v != "TERM=xterm" { + t.Errorf("xterm not set in env for TTY") + } +}