diff --git a/api.go b/api.go index a55b0bd..907314e 100644 --- a/api.go +++ b/api.go @@ -61,6 +61,7 @@ func main() { r.HandleFunc("/ping", handlers.Ping).Methods("GET") corsRouter.HandleFunc("/instances/images", handlers.GetInstanceImages).Methods("GET") corsRouter.HandleFunc("/sessions/{sessionId}", handlers.GetSession).Methods("GET") + corsRouter.HandleFunc("/sessions/{sessionId}/setup", handlers.SessionSetup).Methods("POST") corsRouter.HandleFunc("/sessions/{sessionId}/instances", handlers.NewInstance).Methods("POST") corsRouter.HandleFunc("/sessions/{sessionId}/instances/{instanceName}/uploads", handlers.FileUpload).Methods("POST") corsRouter.HandleFunc("/sessions/{sessionId}/instances/{instanceName}", handlers.DeleteInstance).Methods("DELETE") diff --git a/docker/docker.go b/docker/docker.go index a619527..1bf793d 100644 --- a/docker/docker.go +++ b/docker/docker.go @@ -4,22 +4,27 @@ import ( "archive/tar" "bytes" "context" + "crypto/tls" "fmt" "io" "io/ioutil" "log" "net" + "net/http" "os" "strconv" "strings" "time" "github.com/docker/distribution/reference" + "github.com/docker/docker/api" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/client" "github.com/docker/docker/pkg/jsonmessage" + "github.com/docker/go-connections/tlsconfig" ) const ( @@ -44,6 +49,14 @@ type DockerApi interface { DisconnectNetwork(containerId, networkId string) error DeleteNetwork(id string) error Exec(instanceName string, command []string) (int, error) + New(ip string, cert, key []byte) (DockerApi, error) + SwarmInit() (*SwarmTokens, error) + SwarmJoin(addr, token string) error +} + +type SwarmTokens struct { + Manager string + Worker string } type docker struct { @@ -396,6 +409,73 @@ func (d *docker) DeleteNetwork(id string) error { return nil } +func (d *docker) New(ip string, cert, key []byte) (DockerApi, error) { + // We check if the client needs to use TLS + var tlsConfig *tls.Config + if len(cert) > 0 && len(key) > 0 { + tlsConfig = tlsconfig.ClientDefault() + tlsConfig.InsecureSkipVerify = true + tlsCert, err := tls.X509KeyPair(cert, key) + if err != nil { + return nil, err + } + tlsConfig.Certificates = []tls.Certificate{tlsCert} + } + + transport := &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 1 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext} + if tlsConfig != nil { + transport.TLSClientConfig = tlsConfig + } + cli := &http.Client{ + Transport: transport, + } + c, err := client.NewClient(fmt.Sprintf("http://%s:2375", ip), api.DefaultVersion, cli, nil) + if err != nil { + return nil, fmt.Errorf("Could not connect to DinD docker daemon. %s", err) + } + // try to connect up to 5 times and then give up + for i := 0; i < 5; i++ { + _, err := c.Ping(context.Background()) + if err != nil { + if client.IsErrConnectionFailed(err) { + // connection has failed, maybe instance is not ready yet, sleep and retry + log.Printf("Connection to [%s] has failed, maybe instance is not ready yet, sleeping and retrying in 1 second. Try #%d\n", fmt.Sprintf("http://%s:2375", ip), i+1) + time.Sleep(time.Second) + continue + } + return nil, err + } + } + return NewDocker(c), nil +} + +func (d *docker) SwarmInit() (*SwarmTokens, error) { + req := swarm.InitRequest{AdvertiseAddr: "eth0", ListenAddr: "0.0.0.0:2377"} + _, err := d.c.SwarmInit(context.Background(), req) + + if err != nil { + return nil, err + } + + swarmInfo, err := d.c.SwarmInspect(context.Background()) + if err != nil { + return nil, err + } + + return &SwarmTokens{ + Worker: swarmInfo.JoinTokens.Worker, + Manager: swarmInfo.JoinTokens.Manager, + }, nil +} +func (d *docker) SwarmJoin(addr, token string) error { + req := swarm.JoinRequest{RemoteAddrs: []string{addr}, JoinToken: token, ListenAddr: "0.0.0.0:2377", AdvertiseAddr: "eth0"} + return d.c.SwarmJoin(context.Background(), req) +} + func NewDocker(c *client.Client) *docker { return &docker{c: c} } diff --git a/handlers/session_setup.go b/handlers/session_setup.go new file mode 100644 index 0000000..a72dba5 --- /dev/null +++ b/handlers/session_setup.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/gorilla/mux" + "github.com/play-with-docker/play-with-docker/pwd" +) + +func SessionSetup(rw http.ResponseWriter, req *http.Request) { + vars := mux.Vars(req) + sessionId := vars["sessionId"] + + body := pwd.SessionSetupConf{} + + json.NewDecoder(req.Body).Decode(&body) + + s := core.SessionGet(sessionId) + + if len(s.Instances) > 0 { + log.Println("Cannot setup a session that contains instances") + rw.WriteHeader(http.StatusConflict) + rw.Write([]byte("Cannot setup a session that contains instances")) + return + } + + err := core.SessionSetup(s, body) + if err != nil { + log.Println(err) + rw.WriteHeader(http.StatusInternalServerError) + return + } +} diff --git a/pwd/docker_mock_test.go b/pwd/docker_mock_test.go index c84d638..faa05ff 100644 --- a/pwd/docker_mock_test.go +++ b/pwd/docker_mock_test.go @@ -14,6 +14,10 @@ type mockDocker struct { connectNetwork func(container, network, ip string) (string, error) containerResize func(string, uint, uint) error createContainer func(opts docker.CreateContainerOpts) (string, error) + execAttach func(instanceName string, command []string, out io.Writer) (int, error) + new func(ip string, cert, key []byte) (docker.DockerApi, error) + swarmInit func() (*docker.SwarmTokens, error) + swarmJoin func(addr, token string) error } func (m *mockDocker) CreateNetwork(id string) error { @@ -64,6 +68,9 @@ func (m *mockDocker) CreateContainer(opts docker.CreateContainerOpts) (string, e return "10.0.0.1", nil } func (m *mockDocker) ExecAttach(instanceName string, command []string, out io.Writer) (int, error) { + if m.execAttach != nil { + return m.execAttach(instanceName, command, out) + } return 0, nil } func (m *mockDocker) DisconnectNetwork(containerId, networkId string) error { @@ -75,6 +82,24 @@ func (m *mockDocker) DeleteNetwork(id string) error { func (m *mockDocker) Exec(instanceName string, command []string) (int, error) { return 0, nil } +func (m *mockDocker) New(ip string, cert, key []byte) (docker.DockerApi, error) { + if m.new != nil { + return m.new(ip, cert, key) + } + return nil, nil +} +func (m *mockDocker) SwarmInit() (*docker.SwarmTokens, error) { + if m.swarmInit != nil { + return m.swarmInit() + } + return nil, nil +} +func (m *mockDocker) SwarmJoin(addr, token string) error { + if m.swarmJoin != nil { + return m.swarmJoin(addr, token) + } + return nil +} type mockConn struct { } diff --git a/pwd/pwd.go b/pwd/pwd.go index 04412a8..99d5fde 100644 --- a/pwd/pwd.go +++ b/pwd/pwd.go @@ -46,6 +46,7 @@ type PWDApi interface { SessionDeployStack(session *Session) error SessionGet(id string) *Session SessionLoadAndPrepare() error + SessionSetup(session *Session, conf SessionSetupConf) error InstanceNew(session *Session, conf InstanceConfig) (*Instance, error) InstanceResizeTerminal(instance *Instance, cols, rows uint) error diff --git a/pwd/session.go b/pwd/session.go index ef5aaa5..e64cb28 100644 --- a/pwd/session.go +++ b/pwd/session.go @@ -10,6 +10,7 @@ import ( "time" "github.com/play-with-docker/play-with-docker/config" + "github.com/play-with-docker/play-with-docker/docker" "github.com/twinj/uuid" ) @@ -23,6 +24,17 @@ func (s *sessionBuilderWriter) Write(p []byte) (n int, err error) { return len(p), nil } +type SessionSetupConf struct { + Instances []SessionSetupInstanceConf `json:"instances"` +} + +type SessionSetupInstanceConf struct { + Image string `json:"image"` + Hostname string `json:"hostname"` + IsSwarmManager bool `json:"is_swarm_manager"` + IsSwarmWorker bool `json:"is_swarm_worker"` +} + type Session struct { rw sync.Mutex Id string `json:"id"` @@ -209,6 +221,94 @@ func (p *pwd) SessionLoadAndPrepare() error { return nil } +func (p *pwd) SessionSetup(session *Session, conf SessionSetupConf) error { + var tokens *docker.SwarmTokens = nil + var firstSwarmManager *Instance = nil + + // first look for a swarm manager and create it + for _, conf := range conf.Instances { + if conf.IsSwarmManager { + instanceConf := InstanceConfig{ + ImageName: conf.Image, + Hostname: conf.Hostname, + } + i, err := p.InstanceNew(session, instanceConf) + if err != nil { + return err + } + if i.docker == nil { + dock, err := p.docker.New(i.IP, i.Cert, i.Key) + if err != nil { + return err + } + i.docker = dock + } + tkns, err := i.docker.SwarmInit() + if err != nil { + return err + } + tokens = tkns + firstSwarmManager = i + break + } + } + + // now create the rest in parallel + + wg := sync.WaitGroup{} + for _, c := range conf.Instances { + if firstSwarmManager != nil && c.Hostname != firstSwarmManager.Hostname { + wg.Add(1) + go func(c SessionSetupInstanceConf) { + defer wg.Done() + instanceConf := InstanceConfig{ + ImageName: c.Image, + Hostname: c.Hostname, + } + i, err := p.InstanceNew(session, instanceConf) + if err != nil { + log.Println(err) + return + } + if c.IsSwarmManager || c.IsSwarmWorker { + // check if we have connection to the daemon, if not, create it + if i.docker == nil { + dock, err := p.docker.New(i.IP, i.Cert, i.Key) + if err != nil { + log.Println(err) + return + } + i.docker = dock + } + } + + if firstSwarmManager != nil { + if c.IsSwarmManager { + // this is a swarm manager + // cluster has already been initiated, join as manager + err := i.docker.SwarmJoin(fmt.Sprintf("%s:2377", firstSwarmManager.IP), tokens.Manager) + if err != nil { + log.Println(err) + return + } + } + if c.IsSwarmWorker { + // this is a swarm worker + err := i.docker.SwarmJoin(fmt.Sprintf("%s:2377", firstSwarmManager.IP), tokens.Worker) + if err != nil { + log.Println(err) + return + } + } + } + }(c) + } + } + wg.Wait() + + return nil +} + // This function should be called any time a session needs to be prepared: // 1. Like when it is created // 2. When it was loaded from storage diff --git a/pwd/session_test.go b/pwd/session_test.go index aa4ccfd..9533e2c 100644 --- a/pwd/session_test.go +++ b/pwd/session_test.go @@ -1,10 +1,12 @@ package pwd import ( + "fmt" "testing" "time" "github.com/play-with-docker/play-with-docker/config" + "github.com/play-with-docker/play-with-docker/docker" "github.com/stretchr/testify/assert" ) @@ -80,3 +82,199 @@ func TestSessionNew(t *testing.T) { assert.Equal(t, expectedSessions, sessions) assert.True(t, saveCalled) } + +func TestSessionSetup(t *testing.T) { + swarmInitOnMaster1 := false + manager2JoinedHasManager := false + manager3JoinedHasManager := false + worker1JoinedHasWorker := false + + dock := &mockDocker{} + dock.createContainer = func(opts docker.CreateContainerOpts) (string, error) { + if opts.Hostname == "manager1" { + return "10.0.0.1", nil + } else if opts.Hostname == "manager2" { + return "10.0.0.2", nil + } else if opts.Hostname == "manager3" { + return "10.0.0.3", nil + } else if opts.Hostname == "worker1" { + return "10.0.0.4", nil + } else if opts.Hostname == "other" { + return "10.0.0.5", nil + } else { + assert.Fail(t, "Should not have reached here") + } + return "", nil + } + dock.new = func(ip string, cert, key []byte) (docker.DockerApi, error) { + if ip == "10.0.0.1" { + return &mockDocker{ + swarmInit: func() (*docker.SwarmTokens, error) { + swarmInitOnMaster1 = true + return &docker.SwarmTokens{Worker: "worker-join-token", Manager: "manager-join-token"}, nil + }, + }, nil + } + if ip == "10.0.0.2" { + return &mockDocker{ + swarmInit: func() (*docker.SwarmTokens, error) { + assert.Fail(t, "Shouldn't have reached here.") + return nil, nil + }, + swarmJoin: func(addr, token string) error { + if addr == "10.0.0.1:2377" && token == "manager-join-token" { + manager2JoinedHasManager = true + return nil + } + assert.Fail(t, "Shouldn't have reached here.") + return nil + }, + }, nil + } + if ip == "10.0.0.3" { + return &mockDocker{ + swarmInit: func() (*docker.SwarmTokens, error) { + assert.Fail(t, "Shouldn't have reached here.") + return nil, nil + }, + swarmJoin: func(addr, token string) error { + if addr == "10.0.0.1:2377" && token == "manager-join-token" { + manager3JoinedHasManager = true + return nil + } + assert.Fail(t, "Shouldn't have reached here.") + return nil + }, + }, nil + } + if ip == "10.0.0.4" { + return &mockDocker{ + swarmInit: func() (*docker.SwarmTokens, error) { + assert.Fail(t, "Shouldn't have reached here.") + return nil, nil + }, + swarmJoin: func(addr, token string) error { + if addr == "10.0.0.1:2377" && token == "worker-join-token" { + worker1JoinedHasWorker = true + return nil + } + assert.Fail(t, "Shouldn't have reached here.") + return nil + }, + }, nil + } + assert.Fail(t, "Shouldn't have reached here.") + return nil, nil + } + tasks := &mockTasks{} + broadcast := &mockBroadcast{} + storage := &mockStorage{} + + p := NewPWD(dock, tasks, broadcast, storage) + s, e := p.SessionNew(time.Hour, "", "") + assert.Nil(t, e) + + err := p.SessionSetup(s, SessionSetupConf{ + Instances: []SessionSetupInstanceConf{ + { + Image: "franela/dind", + IsSwarmManager: true, + Hostname: "manager1", + }, + { + IsSwarmManager: true, + Hostname: "manager2", + }, + { + Image: "franela/dind:overlay2-dev", + IsSwarmManager: true, + Hostname: "manager3", + }, + { + IsSwarmWorker: true, + Hostname: "worker1", + }, + { + Hostname: "other", + }, + }, + }) + assert.Nil(t, err) + + assert.Equal(t, 5, len(s.Instances)) + + manager1 := fmt.Sprintf("%s_manager1", s.Id[:8]) + manager1Received := *s.Instances[manager1] + assert.Equal(t, Instance{ + Name: manager1, + Image: "franela/dind", + Hostname: "manager1", + IP: "10.0.0.1", + Alias: "", + IsDockerHost: true, + session: s, + conn: manager1Received.conn, + docker: manager1Received.docker, + }, manager1Received) + + manager2 := fmt.Sprintf("%s_manager2", s.Id[:8]) + manager2Received := *s.Instances[manager2] + assert.Equal(t, Instance{ + Name: manager2, + Image: "franela/dind", + Hostname: "manager2", + IP: "10.0.0.2", + Alias: "", + IsDockerHost: true, + session: s, + conn: manager2Received.conn, + docker: manager2Received.docker, + }, manager2Received) + + manager3 := fmt.Sprintf("%s_manager3", s.Id[:8]) + manager3Received := *s.Instances[manager3] + assert.Equal(t, Instance{ + Name: manager3, + Image: "franela/dind:overlay2-dev", + Hostname: "manager3", + IP: "10.0.0.3", + Alias: "", + IsDockerHost: true, + session: s, + conn: manager3Received.conn, + docker: manager3Received.docker, + }, manager3Received) + + worker1 := fmt.Sprintf("%s_worker1", s.Id[:8]) + worker1Received := *s.Instances[worker1] + assert.Equal(t, Instance{ + Name: worker1, + Image: "franela/dind", + Hostname: "worker1", + IP: "10.0.0.4", + Alias: "", + IsDockerHost: true, + session: s, + conn: worker1Received.conn, + docker: worker1Received.docker, + }, worker1Received) + + other := fmt.Sprintf("%s_other", s.Id[:8]) + otherReceived := *s.Instances[other] + assert.Equal(t, Instance{ + Name: other, + Image: "franela/dind", + Hostname: "other", + IP: "10.0.0.5", + Alias: "", + IsDockerHost: true, + session: s, + conn: otherReceived.conn, + docker: otherReceived.docker, + }, otherReceived) + + assert.True(t, swarmInitOnMaster1) + assert.True(t, manager2JoinedHasManager) + assert.True(t, manager3JoinedHasManager) + assert.True(t, worker1JoinedHasWorker) +} diff --git a/www/assets/app.js b/www/assets/app.js index b23e516..75d735b 100644 --- a/www/assets/app.js +++ b/www/assets/app.js @@ -19,8 +19,8 @@ } } - app.controller('PlayController', ['$scope', '$log', '$http', '$location', '$timeout', '$mdDialog', '$window', 'TerminalService', 'KeyboardShortcutService', 'InstanceService', function($scope, $log, $http, $location, $timeout, $mdDialog, $window, TerminalService, KeyboardShortcutService, InstanceService) { - $scope.sessionId = window.location.pathname.replace('/p/', ''); + app.controller('PlayController', ['$scope', '$log', '$http', '$location', '$timeout', '$mdDialog', '$window', 'TerminalService', 'KeyboardShortcutService', 'InstanceService', 'SessionService', function($scope, $log, $http, $location, $timeout, $mdDialog, $window, TerminalService, KeyboardShortcutService, InstanceService, SessionService) { + $scope.sessionId = SessionService.getCurrentSessionId(); $scope.instances = []; $scope.idx = {}; $scope.selectedInstance = null; @@ -363,7 +363,7 @@ $mdIconProvider.defaultIconSet('../assets/social-icons.svg', 24); }]) .component('settingsIcon', { - template : "settings", + template : "settings", controller : function($mdDialog) { var $ctrl = this; $ctrl.onClick = function() { @@ -376,6 +376,42 @@ } } }) + .component('templatesIcon', { + template : "build", + controller : function($mdDialog) { + var $ctrl = this; + $ctrl.onClick = function() { + $mdDialog.show({ + controller : function() {}, + template : "", + parent: angular.element(document.body), + clickOutsideToClose : true + }) + } + } + }) + .component("templatesDialog", { + templateUrl : "templates-modal.html", + controller : function($mdDialog, $scope, SessionService) { + var $ctrl = this; + $scope.building = false; + $scope.templates = SessionService.getAvailableTemplates(); + $ctrl.close = function() { + $mdDialog.cancel(); + } + $ctrl.setupSession = function(setup) { + $scope.building = true; + SessionService.setup(setup, function(err) { + $scope.building = false; + if (err) { + $scope.errorMessage = err; + return; + } + $ctrl.close(); + }); + } + } + }) .component("settingsDialog", { templateUrl : "settings-modal.html", controller : function($mdDialog, KeyboardShortcutService, $rootScope, InstanceService, TerminalService) { @@ -419,6 +455,58 @@ } } }) + .service("SessionService", function($http) { + var templates = [ + { + title: '3 Managers and 2 Workers', + icon: '/assets/swarm.png', + setup: { + instances: [ + {hostname: 'manager1', is_swarm_manager: true}, + {hostname: 'manager2', is_swarm_manager: true}, + {hostname: 'manager3', is_swarm_manager: true}, + {hostname: 'worker1', is_swarm_worker: true}, + {hostname: 'worker2', is_swarm_worker: true} + ] + } + }, + { + title: '5 Managers and no workers', + icon: '/assets/swarm.png', + setup: { + instances: [ + {hostname: 'manager1', is_swarm_manager: true}, + {hostname: 'manager2', is_swarm_manager: true}, + {hostname: 'manager3', is_swarm_manager: true}, + {hostname: 'manager4', is_swarm_manager: true}, + {hostname: 'manager5', is_swarm_manager: true} + ] + } + } + ]; + + return { + getAvailableTemplates: getAvailableTemplates, + getCurrentSessionId: getCurrentSessionId, + setup: setup, + }; + + function getCurrentSessionId() { + return window.location.pathname.replace('/p/', ''); + } + function getAvailableTemplates() { + return templates; + } + function setup(plan, cb) { + return $http + .post("/sessions/" + getCurrentSessionId() + "/setup", plan) + .then(function(response) { + if (cb) cb(); + }, function(response) { + if (cb) cb(response.data); + }); + } + }) .service("InstanceService", function($http) { var instanceImages = []; _prepopulateAvailableImages(); diff --git a/www/assets/style.css b/www/assets/style.css index a6cbefe..0d429f0 100644 --- a/www/assets/style.css +++ b/www/assets/style.css @@ -59,3 +59,6 @@ md-input-container .md-errors-spacer { background-color: rgba(0,0,0,.5); -webkit-box-shadow: 0 0 1px rgba(255,255,255,.5); } +.md-mini { + min-width: 24px; +} diff --git a/www/assets/swarm.png b/www/assets/swarm.png new file mode 100644 index 0000000..e96af6a Binary files /dev/null and b/www/assets/swarm.png differ diff --git a/www/index.html b/www/index.html index 48ef892..7576672 100644 --- a/www/index.html +++ b/www/index.html @@ -47,6 +47,7 @@ Close session

Instances

+
@@ -145,6 +146,49 @@ +