mirror of
https://github.com/moby/moby.git
synced 2022-11-09 12:21:53 -05:00
Initial commit
This commit is contained in:
commit
a27b4b8cb8
10 changed files with 1146 additions and 0 deletions
203
container.go
Normal file
203
container.go
Normal file
|
@ -0,0 +1,203 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Container struct {
|
||||||
|
Name string
|
||||||
|
Root string
|
||||||
|
Path string
|
||||||
|
Args []string
|
||||||
|
|
||||||
|
*Config
|
||||||
|
*Filesystem
|
||||||
|
*State
|
||||||
|
|
||||||
|
lxcConfigPath string
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdout *writeBroadcaster
|
||||||
|
stderr *writeBroadcaster
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Hostname string
|
||||||
|
Ram int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func createContainer(name string, root string, command string, args []string, layers []string, config *Config) (*Container, error) {
|
||||||
|
container := &Container{
|
||||||
|
Name: name,
|
||||||
|
Root: root,
|
||||||
|
Path: command,
|
||||||
|
Args: args,
|
||||||
|
Config: config,
|
||||||
|
Filesystem: newFilesystem(path.Join(root, "rootfs"), path.Join(root, "rw"), layers),
|
||||||
|
State: newState(),
|
||||||
|
|
||||||
|
lxcConfigPath: path.Join(root, "config.lxc"),
|
||||||
|
stdout: newWriteBroadcaster(),
|
||||||
|
stderr: newWriteBroadcaster(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Mkdir(root, 0700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := container.save(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := container.generateLXCConfig(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return container, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadContainer(containerPath string) (*Container, error) {
|
||||||
|
configPath := path.Join(containerPath, "config.json")
|
||||||
|
fi, err := os.Open(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer fi.Close()
|
||||||
|
enc := json.NewDecoder(fi)
|
||||||
|
container := &Container{}
|
||||||
|
if err := enc.Decode(container); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return container, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (container *Container) save() error {
|
||||||
|
configPath := path.Join(container.Root, "config.json")
|
||||||
|
fo, err := os.Create(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer fo.Close()
|
||||||
|
enc := json.NewEncoder(fo)
|
||||||
|
if err := enc.Encode(container); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (container *Container) generateLXCConfig() error {
|
||||||
|
fo, err := os.Create(container.lxcConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer fo.Close()
|
||||||
|
|
||||||
|
if err := LxcTemplateCompiled.Execute(fo, container); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (container *Container) Start() error {
|
||||||
|
if err := container.Filesystem.Mount(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := []string{
|
||||||
|
"-n", container.Name,
|
||||||
|
"-f", container.lxcConfigPath,
|
||||||
|
"--",
|
||||||
|
container.Path,
|
||||||
|
}
|
||||||
|
params = append(params, container.Args...)
|
||||||
|
|
||||||
|
container.cmd = exec.Command("/usr/bin/lxc-start", params...)
|
||||||
|
container.cmd.Stdout = container.stdout
|
||||||
|
container.cmd.Stderr = container.stderr
|
||||||
|
|
||||||
|
if err := container.cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
container.State.setRunning(container.cmd.Process.Pid)
|
||||||
|
go container.monitor()
|
||||||
|
|
||||||
|
// Wait until we are out of the STARTING state before returning
|
||||||
|
//
|
||||||
|
// Even though lxc-wait blocks until the container reaches a given state,
|
||||||
|
// sometimes it returns an error code, which is why we have to retry.
|
||||||
|
//
|
||||||
|
// This is a rare race condition that happens for short lived programs
|
||||||
|
for retries := 0; retries < 3; retries++ {
|
||||||
|
err := exec.Command("/usr/bin/lxc-wait", "-n", container.Name, "-s", "RUNNING|STOPPED").Run()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("Container failed to start")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (container *Container) Run() error {
|
||||||
|
if err := container.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
container.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (container *Container) Output() (output []byte, err error) {
|
||||||
|
pipe, err := container.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer pipe.Close()
|
||||||
|
if err := container.Start(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
output, err = ioutil.ReadAll(pipe)
|
||||||
|
container.Wait()
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (container *Container) StdoutPipe() (io.ReadCloser, error) {
|
||||||
|
reader, writer := io.Pipe()
|
||||||
|
container.stdout.AddWriter(writer)
|
||||||
|
return newBufReader(reader), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (container *Container) StderrPipe() (io.ReadCloser, error) {
|
||||||
|
reader, writer := io.Pipe()
|
||||||
|
container.stderr.AddWriter(writer)
|
||||||
|
return newBufReader(reader), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (container *Container) monitor() {
|
||||||
|
container.cmd.Wait()
|
||||||
|
container.stdout.Close()
|
||||||
|
container.stderr.Close()
|
||||||
|
container.State.setStopped(container.cmd.ProcessState.Sys().(syscall.WaitStatus).ExitStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (container *Container) Stop() error {
|
||||||
|
if container.State.Running {
|
||||||
|
if err := exec.Command("/usr/bin/lxc-stop", "-n", container.Name).Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
//FIXME: We should lxc-wait for the container to stop
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := container.Filesystem.Umount(); err != nil {
|
||||||
|
// FIXME: Do not abort, probably already umounted?
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (container *Container) Wait() {
|
||||||
|
for container.State.Running {
|
||||||
|
container.State.wait()
|
||||||
|
}
|
||||||
|
}
|
186
container_test.go
Normal file
186
container_test.go
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStart(t *testing.T) {
|
||||||
|
docker, err := newTestDocker()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
container, err := docker.Create(
|
||||||
|
"start_test",
|
||||||
|
"ls",
|
||||||
|
[]string{"-al"},
|
||||||
|
[]string{"/var/lib/docker/images/ubuntu"},
|
||||||
|
&Config{
|
||||||
|
Ram: 33554432,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer docker.Destroy(container)
|
||||||
|
|
||||||
|
if container.State.Running {
|
||||||
|
t.Errorf("Container shouldn't be running")
|
||||||
|
}
|
||||||
|
if err := container.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
container.Wait()
|
||||||
|
if container.State.Running {
|
||||||
|
t.Errorf("Container shouldn't be running")
|
||||||
|
}
|
||||||
|
// We should be able to call Wait again
|
||||||
|
container.Wait()
|
||||||
|
if container.State.Running {
|
||||||
|
t.Errorf("Container shouldn't be running")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun(t *testing.T) {
|
||||||
|
docker, err := newTestDocker()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
container, err := docker.Create(
|
||||||
|
"run_test",
|
||||||
|
"ls",
|
||||||
|
[]string{"-al"},
|
||||||
|
[]string{"/var/lib/docker/images/ubuntu"},
|
||||||
|
&Config{
|
||||||
|
Ram: 33554432,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer docker.Destroy(container)
|
||||||
|
|
||||||
|
if container.State.Running {
|
||||||
|
t.Errorf("Container shouldn't be running")
|
||||||
|
}
|
||||||
|
if err := container.Run(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if container.State.Running {
|
||||||
|
t.Errorf("Container shouldn't be running")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutput(t *testing.T) {
|
||||||
|
docker, err := newTestDocker()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
container, err := docker.Create(
|
||||||
|
"output_test",
|
||||||
|
"echo",
|
||||||
|
[]string{"-n", "foobar"},
|
||||||
|
[]string{"/var/lib/docker/images/ubuntu"},
|
||||||
|
&Config{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer docker.Destroy(container)
|
||||||
|
|
||||||
|
pipe, err := container.StdoutPipe()
|
||||||
|
defer pipe.Close()
|
||||||
|
output, err := container.Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(output) != "foobar" {
|
||||||
|
t.Error(string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStop(t *testing.T) {
|
||||||
|
docker, err := newTestDocker()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
container, err := docker.Create(
|
||||||
|
"stop_test",
|
||||||
|
"sleep",
|
||||||
|
[]string{"300"},
|
||||||
|
[]string{"/var/lib/docker/images/ubuntu"},
|
||||||
|
&Config{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer docker.Destroy(container)
|
||||||
|
|
||||||
|
if container.State.Running {
|
||||||
|
t.Errorf("Container shouldn't be running")
|
||||||
|
}
|
||||||
|
if err := container.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !container.State.Running {
|
||||||
|
t.Errorf("Container should be running")
|
||||||
|
}
|
||||||
|
if err := container.Stop(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if container.State.Running {
|
||||||
|
t.Errorf("Container shouldn't be running")
|
||||||
|
}
|
||||||
|
container.Wait()
|
||||||
|
if container.State.Running {
|
||||||
|
t.Errorf("Container shouldn't be running")
|
||||||
|
}
|
||||||
|
// Try stopping twice
|
||||||
|
if err := container.Stop(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExitCode(t *testing.T) {
|
||||||
|
docker, err := newTestDocker()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trueContainer, err := docker.Create(
|
||||||
|
"exit_test_1",
|
||||||
|
"/bin/true",
|
||||||
|
[]string{""},
|
||||||
|
[]string{"/var/lib/docker/images/ubuntu"},
|
||||||
|
&Config{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer docker.Destroy(trueContainer)
|
||||||
|
if err := trueContainer.Run(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
falseContainer, err := docker.Create(
|
||||||
|
"exit_test_2",
|
||||||
|
"/bin/false",
|
||||||
|
[]string{""},
|
||||||
|
[]string{"/var/lib/docker/images/ubuntu"},
|
||||||
|
&Config{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer docker.Destroy(falseContainer)
|
||||||
|
if err := falseContainer.Run(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if trueContainer.State.ExitCode != 0 {
|
||||||
|
t.Errorf("Unexpected exit code %v", trueContainer.State.ExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if falseContainer.State.ExitCode != 1 {
|
||||||
|
t.Errorf("Unexpected exit code %v", falseContainer.State.ExitCode)
|
||||||
|
}
|
||||||
|
}
|
112
docker.go
Normal file
112
docker.go
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Docker struct {
|
||||||
|
root string
|
||||||
|
repository string
|
||||||
|
containers *list.List
|
||||||
|
}
|
||||||
|
|
||||||
|
func (docker *Docker) List() []*Container {
|
||||||
|
containers := []*Container{}
|
||||||
|
for e := docker.containers.Front(); e != nil; e = e.Next() {
|
||||||
|
containers = append(containers, e.Value.(*Container))
|
||||||
|
}
|
||||||
|
return containers
|
||||||
|
}
|
||||||
|
|
||||||
|
func (docker *Docker) getContainerElement(name string) *list.Element {
|
||||||
|
for e := docker.containers.Front(); e != nil; e = e.Next() {
|
||||||
|
container := e.Value.(*Container)
|
||||||
|
if container.Name == name {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (docker *Docker) Get(name string) *Container {
|
||||||
|
e := docker.getContainerElement(name)
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.Value.(*Container)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (docker *Docker) Exists(name string) bool {
|
||||||
|
return docker.Get(name) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (docker *Docker) Create(name string, command string, args []string, layers []string, config *Config) (*Container, error) {
|
||||||
|
if docker.Exists(name) {
|
||||||
|
return nil, fmt.Errorf("Container %v already exists", name)
|
||||||
|
}
|
||||||
|
root := path.Join(docker.repository, name)
|
||||||
|
container, err := createContainer(name, root, command, args, layers, config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
docker.containers.PushBack(container)
|
||||||
|
return container, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (docker *Docker) Destroy(container *Container) error {
|
||||||
|
element := docker.getContainerElement(container.Name)
|
||||||
|
if element == nil {
|
||||||
|
return fmt.Errorf("Container %v not found - maybe it was already destroyed?", container.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := container.Stop(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(container.Root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
docker.containers.Remove(element)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (docker *Docker) restore() error {
|
||||||
|
dir, err := ioutil.ReadDir(docker.repository)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, v := range dir {
|
||||||
|
container, err := loadContainer(path.Join(docker.repository, v.Name()))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Errorf("Failed to load %v: %v", v.Name(), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
docker.containers.PushBack(container)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() (*Docker, error) {
|
||||||
|
return NewFromDirectory("/var/lib/docker")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFromDirectory(root string) (*Docker, error) {
|
||||||
|
docker := &Docker{
|
||||||
|
root: root,
|
||||||
|
repository: path.Join(root, "containers"),
|
||||||
|
containers: list.New(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Mkdir(docker.repository, 0700); err != nil && !os.IsExist(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := docker.restore(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return docker, nil
|
||||||
|
}
|
175
docker_test.go
Normal file
175
docker_test.go
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestDocker() (*Docker, error) {
|
||||||
|
root, err := ioutil.TempDir("", "docker-test")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
docker, err := NewFromDirectory(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return docker, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate(t *testing.T) {
|
||||||
|
docker, err := newTestDocker()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure we start we 0 containers
|
||||||
|
if len(docker.List()) != 0 {
|
||||||
|
t.Errorf("Expected 0 containers, %v found", len(docker.List()))
|
||||||
|
}
|
||||||
|
container, err := docker.Create(
|
||||||
|
"test_create",
|
||||||
|
"ls",
|
||||||
|
[]string{"-al"},
|
||||||
|
[]string{"/var/lib/docker/images/ubuntu"},
|
||||||
|
&Config{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err := docker.Destroy(container); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Make sure we can find the newly created container with List()
|
||||||
|
if len(docker.List()) != 1 {
|
||||||
|
t.Errorf("Expected 1 container, %v found", len(docker.List()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the container List() returns is the right one
|
||||||
|
if docker.List()[0].Name != "test_create" {
|
||||||
|
t.Errorf("Unexpected container %v returned by List", docker.List()[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure we can get the container with Get()
|
||||||
|
if docker.Get("test_create") == nil {
|
||||||
|
t.Errorf("Unable to get newly created container")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure it is the right container
|
||||||
|
if docker.Get("test_create") != container {
|
||||||
|
t.Errorf("Get() returned the wrong container")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure Exists returns it as existing
|
||||||
|
if !docker.Exists("test_create") {
|
||||||
|
t.Errorf("Exists() returned false for a newly created container")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDestroy(t *testing.T) {
|
||||||
|
docker, err := newTestDocker()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
container, err := docker.Create(
|
||||||
|
"test_destroy",
|
||||||
|
"ls",
|
||||||
|
[]string{"-al"},
|
||||||
|
[]string{"/var/lib/docker/images/ubuntu"},
|
||||||
|
&Config{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Destroy
|
||||||
|
if err := docker.Destroy(container); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure docker.Exists() behaves correctly
|
||||||
|
if docker.Exists("test_destroy") {
|
||||||
|
t.Errorf("Exists() returned true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure docker.List() doesn't list the destroyed container
|
||||||
|
if len(docker.List()) != 0 {
|
||||||
|
t.Errorf("Expected 0 container, %v found", len(docker.List()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure docker.Get() refuses to return the unexisting container
|
||||||
|
if docker.Get("test_destroy") != nil {
|
||||||
|
t.Errorf("Unable to get newly created container")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the container root directory does not exist anymore
|
||||||
|
_, err = os.Stat(container.Root)
|
||||||
|
if err == nil || !os.IsNotExist(err) {
|
||||||
|
t.Errorf("Container root directory still exists after destroy")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test double destroy
|
||||||
|
if err := docker.Destroy(container); err == nil {
|
||||||
|
// It should have failed
|
||||||
|
t.Errorf("Double destroy did not fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet(t *testing.T) {
|
||||||
|
docker, err := newTestDocker()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
container1, err := docker.Create(
|
||||||
|
"test1",
|
||||||
|
"ls",
|
||||||
|
[]string{"-al"},
|
||||||
|
[]string{"/var/lib/docker/images/ubuntu"},
|
||||||
|
&Config{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer docker.Destroy(container1)
|
||||||
|
|
||||||
|
container2, err := docker.Create(
|
||||||
|
"test2",
|
||||||
|
"ls",
|
||||||
|
[]string{"-al"},
|
||||||
|
[]string{"/var/lib/docker/images/ubuntu"},
|
||||||
|
&Config{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer docker.Destroy(container2)
|
||||||
|
|
||||||
|
container3, err := docker.Create(
|
||||||
|
"test3",
|
||||||
|
"ls",
|
||||||
|
[]string{"-al"},
|
||||||
|
[]string{"/var/lib/docker/images/ubuntu"},
|
||||||
|
&Config{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer docker.Destroy(container3)
|
||||||
|
|
||||||
|
if docker.Get("test1") != container1 {
|
||||||
|
t.Errorf("Get(test1) returned %v while expecting %v", docker.Get("test1"), container1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if docker.Get("test2") != container2 {
|
||||||
|
t.Errorf("Get(test2) returned %v while expecting %v", docker.Get("test2"), container2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if docker.Get("test3") != container3 {
|
||||||
|
t.Errorf("Get(test3) returned %v while expecting %v", docker.Get("test3"), container3)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
52
filesystem.go
Normal file
52
filesystem.go
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Filesystem struct {
|
||||||
|
RootFS string
|
||||||
|
RWPath string
|
||||||
|
Layers []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *Filesystem) createMountPoints() error {
|
||||||
|
if err := os.Mkdir(fs.RootFS, 0700); err != nil && !os.IsExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(fs.RWPath, 0700); err != nil && !os.IsExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *Filesystem) Mount() error {
|
||||||
|
if err := fs.createMountPoints(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rwBranch := fmt.Sprintf("%v=rw", fs.RWPath)
|
||||||
|
roBranches := ""
|
||||||
|
for _, layer := range fs.Layers {
|
||||||
|
roBranches += fmt.Sprintf("%v=ro:", layer)
|
||||||
|
}
|
||||||
|
branches := fmt.Sprintf("br:%v:%v", rwBranch, roBranches)
|
||||||
|
cmd := exec.Command("mount", "-t", "aufs", "-o", branches, "none", fs.RootFS)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *Filesystem) Umount() error {
|
||||||
|
return exec.Command("umount", fs.RootFS).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFilesystem(rootfs string, rwpath string, layers []string) *Filesystem {
|
||||||
|
return &Filesystem{
|
||||||
|
RootFS: rootfs,
|
||||||
|
RWPath: rwpath,
|
||||||
|
Layers: layers,
|
||||||
|
}
|
||||||
|
}
|
35
filesystem_test.go
Normal file
35
filesystem_test.go
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilesystem(t *testing.T) {
|
||||||
|
rootfs, err := ioutil.TempDir("", "docker-test-root")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rwpath, err := ioutil.TempDir("", "docker-test-rw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filesystem := newFilesystem(rootfs, rwpath, []string{"/var/lib/docker/images/ubuntu", "/var/lib/docker/images/test"})
|
||||||
|
|
||||||
|
if err := filesystem.Umount(); err == nil {
|
||||||
|
t.Errorf("Umount succeeded even though the filesystem was not mounted")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := filesystem.Mount(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := filesystem.Umount(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := filesystem.Umount(); err == nil {
|
||||||
|
t.Errorf("Umount succeeded even though the filesystem was already umounted")
|
||||||
|
}
|
||||||
|
}
|
94
lxc_template.go
Normal file
94
lxc_template.go
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
const LxcTemplate = `
|
||||||
|
# hostname
|
||||||
|
{{if .Config.Hostname}}
|
||||||
|
lxc.utsname = {{.Config.Hostname}}
|
||||||
|
{{else}}
|
||||||
|
lxc.utsname = {{.Name}}
|
||||||
|
{{end}}
|
||||||
|
#lxc.aa_profile = unconfined
|
||||||
|
|
||||||
|
# network configuration
|
||||||
|
#lxc.network.type = veth
|
||||||
|
#lxc.network.flags = up
|
||||||
|
#lxc.network.link = br0
|
||||||
|
#lxc.network.name = eth0 # Internal container network interface name
|
||||||
|
#lxc.network.mtu = 1500
|
||||||
|
#lxc.network.ipv4 = {ip_address}/{ip_prefix_len}
|
||||||
|
|
||||||
|
# root filesystem
|
||||||
|
lxc.rootfs = {{.Filesystem.RootFS}}
|
||||||
|
|
||||||
|
# use a dedicated pts for the container (and limit the number of pseudo terminal
|
||||||
|
# available)
|
||||||
|
lxc.pts = 1024
|
||||||
|
|
||||||
|
# disable the main console
|
||||||
|
lxc.console = none
|
||||||
|
|
||||||
|
# no controlling tty at all
|
||||||
|
lxc.tty = 1
|
||||||
|
|
||||||
|
# no implicit access to devices
|
||||||
|
lxc.cgroup.devices.deny = a
|
||||||
|
|
||||||
|
# /dev/null and zero
|
||||||
|
lxc.cgroup.devices.allow = c 1:3 rwm
|
||||||
|
lxc.cgroup.devices.allow = c 1:5 rwm
|
||||||
|
|
||||||
|
# consoles
|
||||||
|
lxc.cgroup.devices.allow = c 5:1 rwm
|
||||||
|
lxc.cgroup.devices.allow = c 5:0 rwm
|
||||||
|
lxc.cgroup.devices.allow = c 4:0 rwm
|
||||||
|
lxc.cgroup.devices.allow = c 4:1 rwm
|
||||||
|
|
||||||
|
# /dev/urandom,/dev/random
|
||||||
|
lxc.cgroup.devices.allow = c 1:9 rwm
|
||||||
|
lxc.cgroup.devices.allow = c 1:8 rwm
|
||||||
|
|
||||||
|
# /dev/pts/* - pts namespaces are "coming soon"
|
||||||
|
lxc.cgroup.devices.allow = c 136:* rwm
|
||||||
|
lxc.cgroup.devices.allow = c 5:2 rwm
|
||||||
|
|
||||||
|
# tuntap
|
||||||
|
lxc.cgroup.devices.allow = c 10:200 rwm
|
||||||
|
|
||||||
|
# fuse
|
||||||
|
#lxc.cgroup.devices.allow = c 10:229 rwm
|
||||||
|
|
||||||
|
# rtc
|
||||||
|
#lxc.cgroup.devices.allow = c 254:0 rwm
|
||||||
|
|
||||||
|
|
||||||
|
# standard mount point
|
||||||
|
lxc.mount.entry = proc {{.Filesystem.RootFS}}/proc proc nosuid,nodev,noexec 0 0
|
||||||
|
lxc.mount.entry = sysfs {{.Filesystem.RootFS}}/sys sysfs nosuid,nodev,noexec 0 0
|
||||||
|
lxc.mount.entry = devpts {{.Filesystem.RootFS}}/dev/pts devpts newinstance,ptmxmode=0666,nosuid,noexec 0 0
|
||||||
|
#lxc.mount.entry = varrun {{.Filesystem.RootFS}}/var/run tmpfs mode=755,size=4096k,nosuid,nodev,noexec 0 0
|
||||||
|
#lxc.mount.entry = varlock {{.Filesystem.RootFS}}/var/lock tmpfs size=1024k,nosuid,nodev,noexec 0 0
|
||||||
|
#lxc.mount.entry = shm {{.Filesystem.RootFS}}/dev/shm tmpfs size=65536k,nosuid,nodev,noexec 0 0
|
||||||
|
|
||||||
|
|
||||||
|
# drop linux capabilities (apply mainly to the user root in the container)
|
||||||
|
lxc.cap.drop = audit_control audit_write mac_admin mac_override mknod net_raw setfcap setpcap sys_admin sys_boot sys_module sys_nice sys_pacct sys_rawio sys_resource sys_time sys_tty_config
|
||||||
|
|
||||||
|
# limits
|
||||||
|
{{if .Config.Ram}}
|
||||||
|
lxc.cgroup.memory.limit_in_bytes = {{.Config.Ram}}
|
||||||
|
{{end}}
|
||||||
|
`
|
||||||
|
|
||||||
|
var LxcTemplateCompiled *template.Template
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
LxcTemplateCompiled, err = template.New("lxc").Parse(LxcTemplate)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
48
state.go
Normal file
48
state.go
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
Running bool
|
||||||
|
Pid int
|
||||||
|
ExitCode int
|
||||||
|
|
||||||
|
stateChangeLock *sync.Mutex
|
||||||
|
stateChangeCond *sync.Cond
|
||||||
|
}
|
||||||
|
|
||||||
|
func newState() *State {
|
||||||
|
lock := new(sync.Mutex)
|
||||||
|
return &State{
|
||||||
|
stateChangeLock: lock,
|
||||||
|
stateChangeCond: sync.NewCond(lock),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) setRunning(pid int) {
|
||||||
|
s.Running = true
|
||||||
|
s.ExitCode = 0
|
||||||
|
s.Pid = pid
|
||||||
|
s.broadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) setStopped(exitCode int) {
|
||||||
|
s.Running = false
|
||||||
|
s.Pid = 0
|
||||||
|
s.ExitCode = exitCode
|
||||||
|
s.broadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) broadcast() {
|
||||||
|
s.stateChangeLock.Lock()
|
||||||
|
s.stateChangeCond.Broadcast()
|
||||||
|
s.stateChangeLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) wait() {
|
||||||
|
s.stateChangeLock.Lock()
|
||||||
|
s.stateChangeCond.Wait()
|
||||||
|
s.stateChangeLock.Unlock()
|
||||||
|
}
|
115
utils.go
Normal file
115
utils.go
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"container/list"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bufReader struct {
|
||||||
|
buf *bytes.Buffer
|
||||||
|
reader io.Reader
|
||||||
|
err error
|
||||||
|
l sync.Mutex
|
||||||
|
wait sync.Cond
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBufReader(r io.Reader) *bufReader {
|
||||||
|
reader := &bufReader{
|
||||||
|
buf: &bytes.Buffer{},
|
||||||
|
reader: r,
|
||||||
|
}
|
||||||
|
reader.wait.L = &reader.l
|
||||||
|
go reader.drain()
|
||||||
|
return reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *bufReader) drain() {
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
for {
|
||||||
|
n, err := r.reader.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
r.err = err
|
||||||
|
} else {
|
||||||
|
r.buf.Write(buf[0:n])
|
||||||
|
}
|
||||||
|
r.l.Lock()
|
||||||
|
r.wait.Signal()
|
||||||
|
r.l.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *bufReader) Read(p []byte) (n int, err error) {
|
||||||
|
for {
|
||||||
|
n, err = r.buf.Read(p)
|
||||||
|
if n > 0 {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return 0, r.err
|
||||||
|
}
|
||||||
|
r.l.Lock()
|
||||||
|
r.wait.Wait()
|
||||||
|
r.l.Unlock()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *bufReader) Close() error {
|
||||||
|
closer, ok := r.reader.(io.ReadCloser)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return closer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeBroadcaster struct {
|
||||||
|
writers *list.List
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *writeBroadcaster) AddWriter(writer io.WriteCloser) {
|
||||||
|
w.writers.PushBack(writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *writeBroadcaster) RemoveWriter(writer io.WriteCloser) {
|
||||||
|
for e := w.writers.Front(); e != nil; e = e.Next() {
|
||||||
|
v := e.Value.(io.Writer)
|
||||||
|
if v == writer {
|
||||||
|
w.writers.Remove(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *writeBroadcaster) Write(p []byte) (n int, err error) {
|
||||||
|
failed := []*list.Element{}
|
||||||
|
for e := w.writers.Front(); e != nil; e = e.Next() {
|
||||||
|
writer := e.Value.(io.Writer)
|
||||||
|
if n, err := writer.Write(p); err != nil || n != len(p) {
|
||||||
|
// On error, evict the writer
|
||||||
|
failed = append(failed, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We cannot remove while iterating, so it has to be done in
|
||||||
|
// a separate step
|
||||||
|
for _, e := range failed {
|
||||||
|
w.writers.Remove(e)
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *writeBroadcaster) Close() error {
|
||||||
|
for e := w.writers.Front(); e != nil; e = e.Next() {
|
||||||
|
writer := e.Value.(io.WriteCloser)
|
||||||
|
writer.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWriteBroadcaster() *writeBroadcaster {
|
||||||
|
return &writeBroadcaster{list.New()}
|
||||||
|
}
|
126
utils_test.go
Normal file
126
utils_test.go
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBufReader(t *testing.T) {
|
||||||
|
reader, writer := io.Pipe()
|
||||||
|
bufreader := newBufReader(reader)
|
||||||
|
|
||||||
|
// Write everything down to a Pipe
|
||||||
|
// Usually, a pipe should block but because of the buffered reader,
|
||||||
|
// the writes will go through
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
writer.Write([]byte("hello world"))
|
||||||
|
writer.Close()
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Drain the reader *after* everything has been written, just to verify
|
||||||
|
// it is indeed buffering
|
||||||
|
<-done
|
||||||
|
output, err := ioutil.ReadAll(bufreader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(output, []byte("hello world")) {
|
||||||
|
t.Error(string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type dummyWriter struct {
|
||||||
|
buffer bytes.Buffer
|
||||||
|
failOnWrite bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dw *dummyWriter) Write(p []byte) (n int, err error) {
|
||||||
|
if dw.failOnWrite {
|
||||||
|
return 0, errors.New("Fake fail")
|
||||||
|
}
|
||||||
|
return dw.buffer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dw *dummyWriter) String() string {
|
||||||
|
return dw.buffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dw *dummyWriter) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteBroadcaster(t *testing.T) {
|
||||||
|
writer := newWriteBroadcaster()
|
||||||
|
|
||||||
|
// Test 1: Both bufferA and bufferB should contain "foo"
|
||||||
|
bufferA := &dummyWriter{}
|
||||||
|
writer.AddWriter(bufferA)
|
||||||
|
bufferB := &dummyWriter{}
|
||||||
|
writer.AddWriter(bufferB)
|
||||||
|
writer.Write([]byte("foo"))
|
||||||
|
|
||||||
|
if bufferA.String() != "foo" {
|
||||||
|
t.Errorf("Buffer contains %v", bufferA.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if bufferB.String() != "foo" {
|
||||||
|
t.Errorf("Buffer contains %v", bufferB.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test2: bufferA and bufferB should contain "foobar",
|
||||||
|
// while bufferC should only contain "bar"
|
||||||
|
bufferC := &dummyWriter{}
|
||||||
|
writer.AddWriter(bufferC)
|
||||||
|
writer.Write([]byte("bar"))
|
||||||
|
|
||||||
|
if bufferA.String() != "foobar" {
|
||||||
|
t.Errorf("Buffer contains %v", bufferA.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if bufferB.String() != "foobar" {
|
||||||
|
t.Errorf("Buffer contains %v", bufferB.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if bufferC.String() != "bar" {
|
||||||
|
t.Errorf("Buffer contains %v", bufferC.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test3: Test removal
|
||||||
|
writer.RemoveWriter(bufferB)
|
||||||
|
writer.Write([]byte("42"))
|
||||||
|
if bufferA.String() != "foobar42" {
|
||||||
|
t.Errorf("Buffer contains %v", bufferA.String())
|
||||||
|
}
|
||||||
|
if bufferB.String() != "foobar" {
|
||||||
|
t.Errorf("Buffer contains %v", bufferB.String())
|
||||||
|
}
|
||||||
|
if bufferC.String() != "bar42" {
|
||||||
|
t.Errorf("Buffer contains %v", bufferC.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test4: Test eviction on failure
|
||||||
|
bufferA.failOnWrite = true
|
||||||
|
writer.Write([]byte("fail"))
|
||||||
|
if bufferA.String() != "foobar42" {
|
||||||
|
t.Errorf("Buffer contains %v", bufferA.String())
|
||||||
|
}
|
||||||
|
if bufferC.String() != "bar42fail" {
|
||||||
|
t.Errorf("Buffer contains %v", bufferC.String())
|
||||||
|
}
|
||||||
|
// Even though we reset the flag, no more writes should go in there
|
||||||
|
bufferA.failOnWrite = false
|
||||||
|
writer.Write([]byte("test"))
|
||||||
|
if bufferA.String() != "foobar42" {
|
||||||
|
t.Errorf("Buffer contains %v", bufferA.String())
|
||||||
|
}
|
||||||
|
if bufferC.String() != "bar42failtest" {
|
||||||
|
t.Errorf("Buffer contains %v", bufferC.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Close()
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue