├── .gitignore ├── LICENSE ├── README.md ├── apply.go ├── blue2.jpg ├── destroy.go ├── docker-compose.yml ├── export.go ├── output.go ├── plan.go ├── types.go ├── vendor └── github.com │ └── docker │ └── docker │ └── api │ └── client │ ├── bundlefile │ └── bundlefile.go │ └── stack │ └── common.go └── wp.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Mantika 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Whaleprint 2 | 3 | ![whaleprint](https://github.com/mantika/whaleprint/blob/master/blue2.jpg) 4 | 5 | Whaleprint allows to manage DAB (distributed application bundle) as service blueprints for docker swarm mode 6 | 7 | 8 | ## Rationale 9 | 10 | After playing around with docker experimental [DAB's](https://github.com/docker/docker/blob/master/experimental/docker-stacks-and-bundles.md) we realized 11 | that even though the concept looks promising, the tooling around it was somehow poor and pretty much useless. The only thing that you can do with this as today ise 12 | generate a DAB from a `docker-compose` yml file and then run `docker stack deploy` or `docker deploy` in order to deploy it to your swarm mode cluster and that's pretty much it. 13 | 14 | We immediately started thinking of different ways to enhance the dev & ops experience with this new feature and we came up with some nice ideas that makes this possible. 15 | The main concept behind this project is that we believe service stack deployments (specially in production) should be __transparent__, __reliable__ and above all [__declarative__ and not imperative](https://en.wikipedia.org/wiki/Declarative_programming#Definition). 16 | 17 | Whaleprint makes possible to use your current DAB files as swarm mode blueprints and will show you with __extreme detail__ __exactly__ which and how your services will be deployed/removed. 18 | At the same time it will also handle service update diffs describing precisely what things will change and what will be their new updated value. 19 | 20 | Check it out!: 21 | 22 | [![asciicast](https://asciinema.org/a/9a7oq68a9eoilwqwq459xdr3t.png)](https://asciinema.org/a/9a7oq68a9eoilwqwq459xdr3t) 23 | 24 | 25 | ## What other things does whaleprint do? 26 | 27 | 28 | - Preview and apply your DAB changesets (duh!) 29 | - Extend the current DAB format to support MOAR features. ([#7](https://github.com/mantika/whaleprint/issues/7)) 30 | - Manage multiple stacks simultaneously 31 | - Fetch DAB's from an URL 32 | - Remove and deploy service stacks entirely 33 | - Allow to apply specific service update through the `--target` option 34 | - Outputs relevant computed stack information like Published ports 35 | - Alternatively print complete plan detail instead of changesets only 36 | 37 | 38 | ## How do I use it? 39 | 40 | Check this YouTube video to see a demo: https://www.youtube.com/watch?v=nwtJflxY560 41 | 42 | 43 | ## Installing whaleprint 44 | 45 | Just download the binary for your platform from the [Releases](https://github.com/mantika/whaleprint/releases) section, put it anywher in your PATH and enjoy! 46 | 47 | ## Extending DAB 48 | 49 | Whaleprint not only supports current DAB format but it also extends it in a backward-compatible way and allows to specify some other properties like 50 | Replicas and Constraints (more features to come). 51 | 52 | Here's an example: 53 | 54 | ```javascript 55 | { 56 | "services": { 57 | "vote": { 58 | "Image": "docker/example-voting-app-vote@sha256:20faa449b42b5f0797b1b1a3028a2dd7ac0ece00b0d100b19e6dff4d1a0af2e3", 59 | "EndpointMode": "dnsrr", // Here we set the endpoint mode 60 | "Constraints": [ 61 | "engine.labels.disk == ssd" // We can also add custom constraints 62 | ], 63 | "Replicas": 5, // And set the number of replicas 64 | "Networks": [ 65 | "fruta" 66 | ] 67 | } 68 | }, 69 | "version": "0.1" 70 | } 71 | ``` 72 | 73 | As you can see **Replicas**, **Constraints** and **EndpointMode** are extended features that are not currently supported in the current [DAB specification](https://github.com/docker/docker/blob/master/experimental/docker-stacks-and-bundles.md). Some other features like setting service **PublishedPorts** is also possible. 74 | 75 | ## FAQ 76 | 77 | #### Do I need some custom docker configuration or version for this? 78 | 79 | No, it just works out of the box with your current docker installation 80 | 81 | #### This is cool, in which docker version/platform does it work? 82 | 83 | Whaleprint works in __any__ OS that's currently running docker 1.12 RC 84 | 85 | #### I'm getting connection errors when trying to use whaleprint. 86 | 87 | ~~In OSX this might happen because of an [issue](https://github.com/docker/engine-api/pull/320) in engine-api. 88 | In the meantime just set your `DOCKER_HOST` env variable to your unix socket or TCP connection and you should be ok~~. This issue has been merged into the engine repo, it shouldn't be necessary to do this workaround anymore. 89 | 90 | #### What about performance?. 91 | 92 | It's designed to show results instantly even with a large amount of services. 93 | 94 | 95 | ## Side notes 96 | 97 | While working on whaleprint we learnt a lot from docker internals as well as the new `swarm mode` and `swarmkit` core principles. We also found some issues 98 | ([#1171](https://github.com/docker/swarmkit/issues/1171)) and sent some PR's fixing small bugs ([#320](https://github.com/docker/engine-api/pull/320), [#1207](https://github.com/docker/swarmkit/pull/1207)). 99 | 100 | Some of the concepts and ideas behind Whaleprint are inspired by some other products/companies like HashiCorp terraform who we admire for their excellence and ability to build amazing stuff. 101 | -------------------------------------------------------------------------------- /apply.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | 8 | "github.com/docker/docker/api/client/bundlefile" 9 | "github.com/docker/docker/api/client/stack" 10 | "github.com/docker/docker/api/types" 11 | "github.com/docker/docker/api/types/filters" 12 | "github.com/docker/docker/api/types/network" 13 | "github.com/docker/docker/client" 14 | "github.com/fatih/color" 15 | "github.com/urfave/cli" 16 | "golang.org/x/net/context" 17 | ) 18 | 19 | func apply(c *cli.Context) error { 20 | 21 | stacks, err := getStacks(c) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | swarm, swarmErr := client.NewEnvClient() 27 | if swarmErr != nil { 28 | return cli.NewExitError(swarmErr.Error(), 3) 29 | } 30 | 31 | target := c.StringSlice("target") 32 | targetMap := map[string]bool{} 33 | 34 | for _, name := range target { 35 | targetMap[name] = true 36 | } 37 | 38 | for _, stack := range stacks { 39 | filter := filters.NewArgs() 40 | filter.Add("label", "com.docker.stack.namespace="+stack.Name) 41 | services, servicesErr := swarm.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter}) 42 | if servicesErr != nil { 43 | return cli.NewExitError(servicesErr.Error(), 3) 44 | } 45 | 46 | expected := getBundleServicesSpec(stack.Bundle, stack.Name) 47 | translateNetworkToIds(&expected, swarm, stack.Name) 48 | current := getSwarmServicesSpecForStack(services) 49 | 50 | cyan := color.New(color.FgCyan) 51 | for name, cs := range current { 52 | if _, found := targetMap[cs.Spec.Name]; len(targetMap) == 0 || found { 53 | if _, found := expected[name]; !found { 54 | // service exists but it's not expected, need to delete it 55 | cyan.Printf("Removing service %s\n", name) 56 | servicesErr := swarm.ServiceRemove(context.Background(), name) 57 | if servicesErr != nil { 58 | return cli.NewExitError(servicesErr.Error(), 3) 59 | } 60 | } 61 | } 62 | } 63 | 64 | err = updateNetworks(context.Background(), swarm, getUniqueNetworkNames(stack.Bundle.Services), stack.Name) 65 | 66 | if err != nil { 67 | log.Fatal("Error updating networks when creating services", err) 68 | } 69 | 70 | sp := NewServicePrinter(ioutil.Discard, false) 71 | for name, expectedService := range expected { 72 | if _, found := targetMap[expectedService.Spec.Name]; len(targetMap) == 0 || found { 73 | if currentService, found := current[name]; found { 74 | if sp.PrintServiceSpecDiff(currentService.Spec, expectedService.Spec) { 75 | cyan.Printf("Updating service %s\n", name) 76 | _, servicesErr := swarm.ServiceUpdate(context.Background(), currentService.ID, currentService.Version, expectedService.Spec, types.ServiceUpdateOptions{}) 77 | if servicesErr != nil { 78 | return cli.NewExitError(servicesErr.Error(), 3) 79 | } 80 | } 81 | } else { 82 | // service doesn't exist, need to create a new one 83 | cyan.Printf("Creating service %s\n", name) 84 | _, servicesErr := swarm.ServiceCreate(context.Background(), expectedService.Spec, types.ServiceCreateOptions{}) 85 | if servicesErr != nil { 86 | return cli.NewExitError(servicesErr.Error(), 3) 87 | } 88 | } 89 | } 90 | } 91 | } 92 | 93 | return nil 94 | } 95 | 96 | func updateNetworks( 97 | ctx context.Context, 98 | cli *client.Client, 99 | networks []string, 100 | namespace string, 101 | ) error { 102 | 103 | existingNetworks, err := stack.GetNetworks(ctx, cli, namespace) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | existingNetworkMap := make(map[string]types.NetworkResource) 109 | for _, network := range existingNetworks { 110 | existingNetworkMap[network.Name] = network 111 | } 112 | 113 | createOpts := types.NetworkCreate{ 114 | Labels: stack.GetStackLabels(namespace, nil), 115 | Driver: "overlay", 116 | IPAM: &network.IPAM{Driver: "default"}, 117 | } 118 | 119 | for _, internalName := range networks { 120 | name := fmt.Sprintf("%s_%s", namespace, internalName) 121 | 122 | if _, exists := existingNetworkMap[name]; exists { 123 | continue 124 | } 125 | 126 | fmt.Printf("Creating network %s\n", name) 127 | if _, err := cli.NetworkCreate(ctx, name, createOpts); err != nil { 128 | return err 129 | } 130 | } 131 | return nil 132 | } 133 | 134 | func getUniqueNetworkNames(services map[string]bundlefile.Service) []string { 135 | networkSet := make(map[string]bool) 136 | for _, service := range services { 137 | for _, network := range service.Networks { 138 | networkSet[network] = true 139 | } 140 | } 141 | 142 | networks := []string{} 143 | for network := range networkSet { 144 | networks = append(networks, network) 145 | } 146 | return networks 147 | } 148 | -------------------------------------------------------------------------------- /blue2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mantika/whaleprint/52558a1e985b15e1475b1174d4ca72ad43b15896/blue2.jpg -------------------------------------------------------------------------------- /destroy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "golang.org/x/net/context" 9 | 10 | "github.com/docker/docker/client" 11 | "github.com/fatih/color" 12 | "github.com/urfave/cli" 13 | ) 14 | 15 | func destroy(c *cli.Context) error { 16 | stacks, err := getStacks(c) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | var services []string 22 | for _, stack := range stacks { 23 | for name, _ := range stack.Bundle.Services { 24 | services = append(services, fmt.Sprintf("%s_%s", stack.Name, name)) 25 | } 26 | } 27 | 28 | force := c.Bool("force") 29 | 30 | swarm, swarmErr := client.NewEnvClient() 31 | if swarmErr != nil { 32 | return cli.NewExitError(swarmErr.Error(), 3) 33 | } 34 | 35 | if !force { 36 | fmt.Printf("Are you sure you want to remove the following services? (%s) yes/no: ", strings.Join(services, ", ")) 37 | var input string 38 | fmt.Scanln(&input) 39 | switch { 40 | case input == "no": 41 | log.Fatal("Aborting") 42 | case input == "yes": 43 | default: 44 | log.Fatalf("Incorrect option \"%s\", aborting", input) 45 | } 46 | } 47 | 48 | for _, service := range services { 49 | color.Cyan("Removing service %s\n", service) 50 | servicesErr := swarm.ServiceRemove(context.Background(), service) 51 | if servicesErr != nil { 52 | log.Println("Error removing service %s:, %s", service, err) 53 | } 54 | } 55 | 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | vote: 5 | image: 'docker/example-voting-app-vote:latest' 6 | command: lala 7 | ports: 8 | - "5000:80" 9 | 10 | redis: 11 | image: redis:alpine 12 | ports: ["6379"] 13 | 14 | worker: 15 | image: 'docker/example-voting-app-worker:latest' 16 | 17 | db: 18 | image: postgres:9.4 19 | 20 | result: 21 | image: 'mantika/docker-example-voting-app-result:latest' 22 | ports: 23 | - "5001:80" 24 | - "5858:5858" 25 | -------------------------------------------------------------------------------- /export.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/docker/docker/api/client/bundlefile" 10 | "github.com/docker/docker/api/types" 11 | "github.com/docker/docker/api/types/swarm" 12 | "github.com/docker/docker/client" 13 | "github.com/urfave/cli" 14 | "golang.org/x/net/context" 15 | ) 16 | 17 | func export(c *cli.Context) error { 18 | 19 | swarm, swarmErr := client.NewEnvClient() 20 | if swarmErr != nil { 21 | return cli.NewExitError(swarmErr.Error(), 3) 22 | } 23 | 24 | services, servicesErr := swarm.ServiceList(context.Background(), types.ServiceListOptions{}) 25 | 26 | if len(services) == 0 { 27 | fmt.Println("No services found to export") 28 | return nil 29 | } 30 | 31 | if servicesErr != nil { 32 | return cli.NewExitError(servicesErr.Error(), 3) 33 | } 34 | 35 | bundles := map[string]*bundlefile.Bundlefile{} 36 | for _, service := range services { 37 | var dab *bundlefile.Bundlefile 38 | stackName := getStackName(service.Spec.Labels) 39 | 40 | if dab = bundles[stackName]; dab == nil { 41 | dab = &bundlefile.Bundlefile{Version: "0.1", Services: map[string]bundlefile.Service{}} 42 | bundles[stackName] = dab 43 | } 44 | 45 | bundleService, err := getBundleService(service) 46 | if err != nil { 47 | return cli.NewExitError(servicesErr.Error(), 3) 48 | } 49 | 50 | // Remove the stackname from the service in DAB 51 | service.Spec.Name = strings.TrimPrefix(service.Spec.Name, fmt.Sprintf("%s_", stackName)) 52 | 53 | dab.Services[service.Spec.Name] = *bundleService 54 | 55 | } 56 | 57 | for output, bundle := range bundles { 58 | f, err := os.Create(fmt.Sprintf("%s.dab", output)) 59 | if err != nil { 60 | return cli.NewExitError(servicesErr.Error(), 3) 61 | } 62 | 63 | err = json.NewEncoder(f).Encode(bundle) 64 | if err != nil { 65 | return cli.NewExitError(servicesErr.Error(), 3) 66 | } 67 | 68 | fmt.Printf("Swarm services exported successfuly for stack: %s \n", output) 69 | for name, _ := range bundle.Services { 70 | fmt.Println(name) 71 | } 72 | fmt.Println() 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func getStackName(labels map[string]string) string { 79 | if stackName, ok := labels["com.docker.stack.namespace"]; ok { 80 | return stackName 81 | } 82 | return "services" 83 | 84 | } 85 | 86 | func getBundleService(service swarm.Service) (*bundlefile.Service, error) { 87 | serviceBundle := &bundlefile.Service{ 88 | Image: service.Spec.TaskTemplate.ContainerSpec.Image, 89 | ServiceLabels: service.Spec.TaskTemplate.ContainerSpec.Labels, 90 | Labels: service.Spec.Labels, 91 | Command: service.Spec.TaskTemplate.ContainerSpec.Command, 92 | Args: service.Spec.TaskTemplate.ContainerSpec.Args, 93 | Env: service.Spec.TaskTemplate.ContainerSpec.Env, 94 | WorkingDir: &service.Spec.TaskTemplate.ContainerSpec.Dir, 95 | User: &service.Spec.TaskTemplate.ContainerSpec.User, 96 | Ports: []bundlefile.Port{}, 97 | Networks: []string{}, 98 | } 99 | 100 | if service.Spec.Mode.Global != nil { 101 | global := "global" 102 | serviceBundle.Mode = &global 103 | } else { 104 | serviceBundle.Replicas = service.Spec.Mode.Replicated.Replicas 105 | } 106 | 107 | for _, portcfg := range service.Endpoint.Spec.Ports { 108 | port := bundlefile.Port{ 109 | Protocol: string(portcfg.Protocol), 110 | Port: portcfg.TargetPort, 111 | PublishedPort: portcfg.PublishedPort, 112 | } 113 | serviceBundle.Ports = append(serviceBundle.Ports, port) 114 | } 115 | 116 | for _, net := range service.Spec.Networks { 117 | serviceBundle.Networks = append(serviceBundle.Networks, net.Aliases...) 118 | } 119 | 120 | return serviceBundle, nil 121 | } 122 | -------------------------------------------------------------------------------- /output.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/docker/docker/api/types/filters" 8 | "github.com/docker/docker/client" 9 | "github.com/fatih/color" 10 | "github.com/urfave/cli" 11 | "golang.org/x/net/context" 12 | ) 13 | 14 | func output(c *cli.Context) error { 15 | stacks, err := getStacks(c) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | swarm, swarmErr := client.NewEnvClient() 21 | if swarmErr != nil { 22 | return cli.NewExitError(swarmErr.Error(), 3) 23 | } 24 | 25 | for _, stack := range stacks { 26 | filter := filters.NewArgs() 27 | filter.Add("label", "com.docker.stack.namespace="+stack.Name) 28 | services, servicesErr := swarm.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter}) 29 | if servicesErr != nil { 30 | return cli.NewExitError(servicesErr.Error(), 3) 31 | } 32 | 33 | current := getSwarmServicesSpecForStack(services) 34 | 35 | for _, s := range current { 36 | color.Green("%s\n", s.Spec.Name) 37 | fmt.Println(" - Published Ports") 38 | 39 | for _, port := range s.Endpoint.Ports { 40 | fmt.Printf(" %d => %d\n", port.PublishedPort, port.TargetPort) 41 | } 42 | 43 | fmt.Println() 44 | } 45 | } 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /plan.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "os" 8 | "sort" 9 | 10 | "golang.org/x/net/context" 11 | 12 | "github.com/docker/docker/api/client/bundlefile" 13 | "github.com/docker/docker/api/client/stack" 14 | "github.com/docker/docker/api/types" 15 | "github.com/docker/docker/api/types/filters" 16 | "github.com/docker/docker/api/types/swarm" 17 | "github.com/docker/docker/client" 18 | "github.com/fatih/color" 19 | "github.com/urfave/cli" 20 | ) 21 | 22 | var Replica1 uint64 = 1 23 | 24 | type Services map[string]swarm.Service 25 | 26 | func (s Services) Keys() []string { 27 | keys := make([]string, 0, len(s)) 28 | for k := range s { 29 | keys = append(keys, k) 30 | } 31 | sort.Strings(keys) 32 | 33 | return keys 34 | } 35 | 36 | func plan(c *cli.Context) error { 37 | stacks, err := getStacks(c) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | detail := c.Bool("detail") 43 | target := c.StringSlice("target") 44 | targetMap := map[string]bool{} 45 | 46 | for _, name := range target { 47 | targetMap[name] = true 48 | } 49 | 50 | swarm, swarmErr := client.NewEnvClient() 51 | if swarmErr != nil { 52 | return cli.NewExitError(swarmErr.Error(), 3) 53 | } 54 | 55 | for _, stack := range stacks { 56 | filter := filters.NewArgs() 57 | filter.Add("label", "com.docker.stack.namespace="+stack.Name) 58 | services, servicesErr := swarm.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter}) 59 | if servicesErr != nil { 60 | return cli.NewExitError(servicesErr.Error(), 3) 61 | } 62 | 63 | expected := getBundleServicesSpec(stack.Bundle, stack.Name) 64 | translateNetworkToIds(&expected, swarm, stack.Name) 65 | 66 | current := getSwarmServicesSpecForStack(services) 67 | 68 | w := bufio.NewWriter(os.Stdout) 69 | sp := NewServicePrinter(w, detail) 70 | 71 | for _, n := range expected.Keys() { 72 | es := expected[n] 73 | // Only process found target services 74 | if _, found := targetMap[es.Spec.Name]; len(targetMap) == 0 || found { 75 | if cs, found := current[n]; !found { 76 | // New service to create 77 | color.Green("+ %s", n) 78 | sp.PrintServiceSpec(es.Spec) 79 | w.Flush() 80 | fmt.Println() 81 | } else { 82 | different := sp.PrintServiceSpecDiff(cs.Spec, es.Spec) 83 | if different { 84 | color.Yellow("~ %s\n", es.Spec.Name) 85 | } else if detail { 86 | color.Cyan("%s\n", es.Spec.Name) 87 | } 88 | 89 | // flush if results 90 | if different || detail { 91 | w.Flush() 92 | fmt.Println() 93 | } 94 | } 95 | } 96 | } 97 | 98 | // Checks services to remove 99 | for _, n := range current.Keys() { 100 | cs := current[n] 101 | // Only process found target services 102 | if _, found := targetMap[cs.Spec.Name]; len(targetMap) == 0 || found { 103 | if _, found := expected[n]; !found { 104 | color.Red("- %s", n) 105 | sp.PrintServiceSpec(cs.Spec) 106 | w.Flush() 107 | fmt.Println() 108 | } 109 | } 110 | 111 | } 112 | } 113 | 114 | return nil 115 | } 116 | 117 | func safeDereference(p *string) string { 118 | if p == nil { 119 | return "" 120 | } 121 | return *p 122 | } 123 | 124 | func translateNetworkToIds(services *Services, cli *client.Client, stackName string) { 125 | existingNetworks, err := stack.GetNetworks(context.Background(), cli, stackName) 126 | if err != nil { 127 | log.Fatal("Error retrieving networks") 128 | } 129 | 130 | for _, service := range *services { 131 | for i, network := range service.Spec.Networks { 132 | for _, enet := range existingNetworks { 133 | if enet.Name == network.Target { 134 | service.Spec.Networks[i].Target = enet.ID 135 | network.Target = enet.ID 136 | } 137 | } 138 | } 139 | } 140 | } 141 | 142 | func getBundleServicesSpec(bundle *bundlefile.Bundlefile, stackName string) Services { 143 | specs := Services{} 144 | 145 | for name, service := range bundle.Services { 146 | spec := swarm.ServiceSpec{ 147 | TaskTemplate: swarm.TaskSpec{ 148 | ContainerSpec: swarm.ContainerSpec{ 149 | Image: service.Image, 150 | Labels: map[string]string{"com.docker.stack.namespace": stackName}, 151 | Command: service.Command, 152 | Args: service.Args, 153 | Env: service.Env, 154 | Dir: safeDereference(service.WorkingDir), 155 | User: safeDereference(service.User), 156 | }, 157 | Placement: &swarm.Placement{Constraints: service.Constraints}, 158 | }, 159 | Networks: convertNetworks(service.Networks, stackName, name), 160 | } 161 | 162 | spec.Mode = getServiceMode(service.Mode) 163 | 164 | if service.Replicas != nil { 165 | spec.Mode.Replicated.Replicas = service.Replicas 166 | } 167 | 168 | spec.Labels = map[string]string{"com.docker.stack.namespace": stackName} 169 | 170 | for name, value := range service.Labels { 171 | spec.Labels[name] = value 172 | } 173 | 174 | spec.Name = fmt.Sprintf("%s_%s", stackName, name) 175 | 176 | // Populate ports 177 | ports := []swarm.PortConfig{} 178 | for _, port := range service.Ports { 179 | p := swarm.PortConfig{ 180 | TargetPort: port.Port, 181 | Protocol: swarm.PortConfigProtocol(port.Protocol), 182 | PublishedPort: port.PublishedPort, 183 | } 184 | 185 | ports = append(ports, p) 186 | } 187 | // Hardcode resolution mode to VIP as it's the default with dab 188 | mode := "vip" 189 | if service.EndpointMode != nil { 190 | if *service.EndpointMode != "dnsrr" && *service.EndpointMode != "vip" { 191 | log.Fatalf("Invalid mode \"%s\" for service %s, only \"dnsrr\" or \"vip\" is allowed", *service.EndpointMode, spec.Name) 192 | } 193 | mode = *service.EndpointMode 194 | } 195 | spec.EndpointSpec = &swarm.EndpointSpec{Ports: ports, Mode: swarm.ResolutionMode(mode)} 196 | 197 | service := swarm.Service{} 198 | service.ID = spec.Name 199 | service.Spec = spec 200 | 201 | specs[spec.Name] = service 202 | } 203 | return specs 204 | } 205 | 206 | func getServiceMode(mode *string) swarm.ServiceMode { 207 | if mode != nil && *mode == "global" { 208 | return swarm.ServiceMode{ 209 | Global: &swarm.GlobalService{}, 210 | } 211 | } else { 212 | return swarm.ServiceMode{ 213 | Replicated: &swarm.ReplicatedService{ 214 | Replicas: &Replica1, 215 | }, 216 | } 217 | } 218 | } 219 | 220 | func convertNetworks(networks []string, namespace string, name string) []swarm.NetworkAttachmentConfig { 221 | nets := []swarm.NetworkAttachmentConfig{} 222 | for _, network := range networks { 223 | nets = append(nets, swarm.NetworkAttachmentConfig{ 224 | Target: namespace + "_" + network, 225 | Aliases: []string{network, name}, 226 | }) 227 | } 228 | return nets 229 | } 230 | 231 | func getSwarmServicesSpecForStack(services []swarm.Service) Services { 232 | specs := Services{} 233 | 234 | for _, service := range services { 235 | specs[service.Spec.Name] = service 236 | } 237 | 238 | return specs 239 | } 240 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "math" 8 | "reflect" 9 | "strings" 10 | 11 | "github.com/docker/docker/api/client/bundlefile" 12 | "github.com/docker/docker/api/types/swarm" 13 | "github.com/fatih/color" 14 | ) 15 | 16 | var yellow = color.New(color.FgYellow) 17 | 18 | type Stack struct { 19 | Name string 20 | Bundle *bundlefile.Bundlefile 21 | } 22 | 23 | type ServicePrinter struct { 24 | w io.Writer 25 | detail bool 26 | isDifferent bool 27 | } 28 | 29 | func NewServicePrinter(w io.Writer, detail bool) *ServicePrinter { 30 | return &ServicePrinter{w: w, detail: detail} 31 | } 32 | 33 | func (sp *ServicePrinter) PrintServiceSpec(spec swarm.ServiceSpec) { 34 | sp.isDifferent = false 35 | sp._printServiceSpec("", spec) 36 | } 37 | 38 | func (sp *ServicePrinter) _printServiceSpec(namespace string, current interface{}) { 39 | currentType := reflect.TypeOf(current) 40 | currentValue := reflect.ValueOf(current) 41 | switch currentType.Kind() { 42 | case reflect.Array, reflect.Slice: 43 | for i := 0; i < currentValue.Len(); i++ { 44 | newNamespace := fmt.Sprintf("%s[%d]", namespace, i) 45 | sp._printServiceSpec(newNamespace, currentValue.Index(i).Interface()) 46 | } 47 | case reflect.Map: 48 | for _, k := range currentValue.MapKeys() { 49 | newNamespace := fmt.Sprintf("%s.%s", namespace, k.Interface()) 50 | sp._printServiceSpec(newNamespace, currentValue.MapIndex(k).Interface()) 51 | } 52 | case reflect.Ptr: 53 | if !currentValue.IsNil() { 54 | sp._printServiceSpec(namespace, reflect.Indirect(currentValue).Interface()) 55 | } 56 | case reflect.Struct: 57 | for i := 0; i < currentValue.NumField(); i++ { 58 | f := currentValue.Type().Field(i) 59 | if f.PkgPath == "" { 60 | newNamespace := fmt.Sprintf("%s.%s", namespace, f.Name) 61 | sp._printServiceSpec(newNamespace, currentValue.Field(i).Interface()) 62 | } 63 | } 64 | default: 65 | sc := fmt.Sprint(current) 66 | sp.println(nil, namespace, sc) 67 | } 68 | } 69 | 70 | func (sp *ServicePrinter) PrintServiceSpecDiff(current, expected swarm.ServiceSpec) bool { 71 | sp.isDifferent = false 72 | sp._printServiceSpecDiff("", current, expected) 73 | return sp.isDifferent 74 | } 75 | 76 | func (sp *ServicePrinter) _printServiceSpecDiff(namespace string, current, expected interface{}) { 77 | currentType := reflect.TypeOf(current) 78 | expectedType := reflect.TypeOf(expected) 79 | currentValue := reflect.ValueOf(current) 80 | expectedValue := reflect.ValueOf(expected) 81 | 82 | if currentType != expectedType { 83 | log.Fatal("Types are different ", currentType, expectedType) 84 | } 85 | 86 | switch currentType.Kind() { 87 | case reflect.Array, reflect.Slice: 88 | c := int(math.Max(float64(currentValue.Len()), float64(expectedValue.Len()))) 89 | 90 | for i := 0; i < c; i++ { 91 | newNamespace := fmt.Sprintf("%s[%d]", namespace, i) 92 | if i >= currentValue.Len() { 93 | sp._printServiceSpecDiff(newNamespace, reflect.Zero(expectedValue.Index(i).Type()).Interface(), expectedValue.Index(i).Interface()) 94 | } else if i >= expectedValue.Len() { 95 | sp._printServiceSpecDiff(newNamespace, currentValue.Index(i).Interface(), reflect.Zero(currentValue.Index(i).Type()).Interface()) 96 | } else if i >= expectedValue.Len() { 97 | sp._printServiceSpecDiff(newNamespace, currentValue.Index(i).Interface(), reflect.Indirect(reflect.New(currentValue.Index(i).Type())).Interface()) 98 | } else { 99 | sp._printServiceSpecDiff(newNamespace, currentValue.Index(i).Interface(), expectedValue.Index(i).Interface()) 100 | } 101 | } 102 | 103 | case reflect.Map: 104 | for _, k := range currentValue.MapKeys() { 105 | ev := expectedValue.MapIndex(k) 106 | var expectedKeyValue interface{} 107 | if ev.IsValid() { 108 | expectedKeyValue = ev.Interface() 109 | } else { 110 | expectedKeyValue = reflect.Zero(currentValue.MapIndex(k).Type()).Interface() 111 | } 112 | newNamespace := fmt.Sprintf("%s.%s", namespace, k.Interface()) 113 | sp._printServiceSpecDiff(newNamespace, currentValue.MapIndex(k).Interface(), expectedKeyValue) 114 | } 115 | 116 | for _, k := range expectedValue.MapKeys() { 117 | cv := currentValue.MapIndex(k) 118 | var currentKeyValue interface{} 119 | if cv.IsValid() { 120 | continue 121 | } else { 122 | currentKeyValue = reflect.Zero(expectedValue.MapIndex(k).Type()).Interface() 123 | } 124 | newNamespace := fmt.Sprintf("%s.%s", namespace, k.Interface()) 125 | sp._printServiceSpecDiff(newNamespace, currentKeyValue, expectedValue.MapIndex(k).Interface()) 126 | } 127 | 128 | case reflect.Ptr: 129 | var dcv interface{} 130 | var dev interface{} 131 | 132 | if currentValue.IsNil() { 133 | dcv = reflect.Zero(currentType.Elem()).Interface() 134 | } else { 135 | dcv = reflect.Indirect(currentValue).Interface() 136 | } 137 | 138 | if expectedValue.IsNil() { 139 | dev = reflect.Zero(expectedType.Elem()).Interface() 140 | } else { 141 | dev = reflect.Indirect(expectedValue).Interface() 142 | } 143 | 144 | sp._printServiceSpecDiff(namespace, dcv, dev) 145 | 146 | case reflect.Struct: 147 | for i := 0; i < currentValue.NumField(); i++ { 148 | f := currentValue.Type().Field(i) 149 | if f.PkgPath == "" { 150 | newNamespace := fmt.Sprintf("%s.%s", namespace, f.Name) 151 | sp._printServiceSpecDiff(newNamespace, currentValue.Field(i).Interface(), expectedValue.Field(i).Interface()) 152 | } 153 | } 154 | default: 155 | sc := fmt.Sprint(current) 156 | se := fmt.Sprint(expected) 157 | 158 | if sc == se { 159 | if sp.detail { 160 | sp.printDiffln(nil, namespace, sc, se) 161 | } 162 | } else { 163 | var c *color.Color 164 | if sp.detail { 165 | c = yellow 166 | } 167 | 168 | sp.isDifferent = true 169 | sp.printDiffln(c, namespace, sc, se) 170 | } 171 | } 172 | } 173 | 174 | func (sp *ServicePrinter) println(c *color.Color, namespace, current string) { 175 | spaces := 70 - len(namespace) 176 | spaceString := strings.Repeat(" ", spaces) 177 | if c != nil { 178 | namespace = c.SprintFunc()(namespace) 179 | current = c.SprintFunc()(current) 180 | } 181 | fmt.Fprintf(sp.w, " %s:%s\"%s\"\n", namespace, spaceString, current) 182 | } 183 | func (sp *ServicePrinter) printDiffln(c *color.Color, namespace, current, expected string) { 184 | action := "=>" 185 | spaces := 70 - len(namespace) 186 | spaceString := strings.Repeat(" ", spaces) 187 | if c != nil { 188 | namespace = c.SprintFunc()(namespace) 189 | current = c.SprintFunc()(current) 190 | expected = c.SprintFunc()(expected) 191 | action = c.SprintFunc()(action) 192 | } 193 | fmt.Fprintf(sp.w, " %s:%s\"%s\" %s \"%s\"\n", namespace, spaceString, current, action, expected) 194 | } 195 | -------------------------------------------------------------------------------- /vendor/github.com/docker/docker/api/client/bundlefile/bundlefile.go: -------------------------------------------------------------------------------- 1 | package bundlefile 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | // Bundlefile stores the contents of a bundlefile 10 | type Bundlefile struct { 11 | Version string 12 | Services map[string]Service 13 | } 14 | 15 | // Service is a service from a bundlefile 16 | type Service struct { 17 | Image string 18 | Command []string `json:",omitempty"` 19 | Args []string `json:",omitempty"` 20 | Env []string `json:",omitempty"` 21 | Labels map[string]string `json:",omitempty"` 22 | ServiceLabels map[string]string `json:",omitempty"` 23 | Ports []Port `json:",omitempty"` 24 | WorkingDir *string `json:",omitempty"` 25 | User *string `json:",omitempty"` 26 | Networks []string `json:",omitempty"` 27 | Replicas *uint64 `json:",omitempty"` 28 | Constraints []string `json:",omitempty"` 29 | EndpointMode *string `json:",omitempty"` 30 | Mode *string `json:",omitempty"` 31 | } 32 | 33 | // Port is a port as defined in a bundlefile 34 | type Port struct { 35 | Protocol string 36 | Port uint32 37 | PublishedPort uint32 38 | } 39 | 40 | // LoadFile loads a bundlefile from a path to the file 41 | func LoadFile(reader io.Reader) (*Bundlefile, error) { 42 | bundlefile := &Bundlefile{} 43 | 44 | decoder := json.NewDecoder(reader) 45 | if err := decoder.Decode(bundlefile); err != nil { 46 | switch jsonErr := err.(type) { 47 | case *json.SyntaxError: 48 | return nil, fmt.Errorf( 49 | "JSON syntax error at byte %v: %s", 50 | jsonErr.Offset, 51 | jsonErr.Error()) 52 | case *json.UnmarshalTypeError: 53 | return nil, fmt.Errorf( 54 | "Unexpected type at byte %v. Expected %s but received %s.", 55 | jsonErr.Offset, 56 | jsonErr.Type, 57 | jsonErr.Value) 58 | } 59 | return nil, err 60 | } 61 | 62 | return bundlefile, nil 63 | } 64 | 65 | // Print writes the contents of the bundlefile to the output writer 66 | // as human readable json 67 | func Print(out io.Writer, bundle *Bundlefile) error { 68 | bytes, err := json.MarshalIndent(*bundle, "", " ") 69 | if err != nil { 70 | return err 71 | } 72 | 73 | _, err = out.Write(bytes) 74 | return err 75 | } 76 | -------------------------------------------------------------------------------- /vendor/github.com/docker/docker/api/client/stack/common.go: -------------------------------------------------------------------------------- 1 | package stack 2 | 3 | import ( 4 | "github.com/docker/docker/api/types" 5 | "github.com/docker/docker/api/types/filters" 6 | "github.com/docker/docker/api/types/swarm" 7 | "github.com/docker/docker/client" 8 | "golang.org/x/net/context" 9 | ) 10 | 11 | const ( 12 | labelNamespace = "com.docker.stack.namespace" 13 | ) 14 | 15 | func GetStackLabels(namespace string, labels map[string]string) map[string]string { 16 | if labels == nil { 17 | labels = make(map[string]string) 18 | } 19 | labels[labelNamespace] = namespace 20 | return labels 21 | } 22 | 23 | func GetStackFilter(namespace string) filters.Args { 24 | filter := filters.NewArgs() 25 | filter.Add("label", labelNamespace+"="+namespace) 26 | return filter 27 | } 28 | 29 | func GetServices( 30 | ctx context.Context, 31 | apiclient client.APIClient, 32 | namespace string, 33 | ) ([]swarm.Service, error) { 34 | return apiclient.ServiceList( 35 | ctx, 36 | types.ServiceListOptions{Filters: GetStackFilter(namespace)}) 37 | } 38 | 39 | func GetNetworks( 40 | ctx context.Context, 41 | apiclient client.APIClient, 42 | namespace string, 43 | ) ([]types.NetworkResource, error) { 44 | return apiclient.NetworkList( 45 | ctx, 46 | types.NetworkListOptions{Filters: GetStackFilter(namespace)}) 47 | } 48 | -------------------------------------------------------------------------------- /wp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "net/url" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/docker/docker/api/client/bundlefile" 14 | "github.com/urfave/cli" 15 | ) 16 | 17 | func main() { 18 | app := cli.NewApp() 19 | app.Usage = "Manage DAB files as docker swarm service blueprints" 20 | app.Version = "0.0.3" 21 | 22 | app.Commands = []cli.Command{ 23 | { 24 | Name: "plan", 25 | Usage: "Plan service deployment", 26 | ArgsUsage: `[STACK] [STACK...] 27 | 28 | Prints an execultion plan to review before applying changes. 29 | Whaleprint will look for .dab files or use the stack name to load the DAB file. 30 | `, 31 | Action: plan, 32 | Flags: []cli.Flag{ 33 | cli.StringFlag{ 34 | Name: "file, f", 35 | Usage: "DAB file to use", 36 | }, 37 | cli.BoolFlag{ 38 | Name: "detail", 39 | Usage: "Show all properties instead of changes only", 40 | }, 41 | cli.StringSliceFlag{ 42 | Name: "target", 43 | Usage: "Process specified services only (default [])", 44 | }, 45 | }, 46 | }, 47 | { 48 | Name: "apply", 49 | Usage: "Apply service deployment", 50 | ArgsUsage: `[STACK] [STACK...] 51 | 52 | Applies the execution plan returned by the "whaleprint plan" command 53 | Whaleprint will look for .dab files or use the stack name to load the DAB file. 54 | `, 55 | Action: apply, 56 | Flags: []cli.Flag{ 57 | cli.StringFlag{ 58 | Name: "file, f", 59 | Usage: "DAB file to use", 60 | }, 61 | cli.StringSliceFlag{ 62 | Name: "target", 63 | Usage: "Process specified services only (default [])", 64 | }, 65 | }, 66 | }, 67 | { 68 | Name: "export", 69 | Usage: "Export stacks to DAB", 70 | ArgsUsage: ` 71 | Exports current service definitions to a DAB file 72 | `, 73 | Action: export, 74 | }, 75 | { 76 | Name: "destroy", 77 | Usage: "Destroy a DAB stack", 78 | ArgsUsage: `[STACK] [STACK...] 79 | 80 | Destroys the stack present in the DAB file 81 | Whaleprint will look for .dab files use the stack name to load the DAB file. 82 | `, 83 | Action: destroy, 84 | Flags: []cli.Flag{ 85 | cli.StringFlag{ 86 | Name: "file, f", 87 | Usage: "DAB file to use", 88 | }, 89 | cli.BoolFlag{ 90 | Name: "force", 91 | Usage: "Ignore destroy DAB file to useconfirmation", 92 | }, 93 | cli.StringSliceFlag{ 94 | Name: "target", 95 | Usage: "Process specified services only (default [])", 96 | }, 97 | }, 98 | }, 99 | { 100 | Name: "output", 101 | Usage: "Show import output information stacks", 102 | ArgsUsage: `[STACK] [STACK...] 103 | 104 | Show important information for the specified stacks. 105 | `, 106 | Action: output, 107 | Flags: []cli.Flag{ 108 | cli.StringFlag{ 109 | Name: "file, f", 110 | Usage: "DAB file to use", 111 | }, 112 | }, 113 | }, 114 | } 115 | 116 | app.Run(os.Args) 117 | } 118 | 119 | func getStacksFromCWD() []string { 120 | files, err := ioutil.ReadDir(".") 121 | if err != nil { 122 | log.Fatal("Error fetching files from current dir", err) 123 | } 124 | 125 | dabs := []string{} 126 | 127 | for _, file := range files { 128 | if strings.HasSuffix(file.Name(), ".dab") { 129 | dabs = append(dabs, strings.TrimSuffix(file.Name(), ".dab")) 130 | } 131 | } 132 | 133 | if len(dabs) == 0 { 134 | log.Fatal("No DABs found in current directory") 135 | } 136 | 137 | return dabs 138 | } 139 | 140 | func getStacks(c *cli.Context) ([]Stack, error) { 141 | type stackDefinition struct { 142 | name string 143 | file string 144 | } 145 | 146 | defs := []stackDefinition{} 147 | 148 | stackNames := c.Args() 149 | dabFile := c.String("file") 150 | 151 | if dabFile != "" { 152 | if len(stackNames) > 1 { 153 | return nil, cli.NewExitError("You can only specify one stack name when using -f", 1) 154 | } else if len(stackNames) == 1 { 155 | defs = append(defs, stackDefinition{name: stackNames[0], file: dabFile}) 156 | } else { 157 | stackName := strings.TrimSuffix(filepath.Base(dabFile), filepath.Ext(dabFile)) 158 | defs = append(defs, stackDefinition{name: stackName, file: dabFile}) 159 | } 160 | } else if len(stackNames) == 0 { 161 | stackNames = getStacksFromCWD() 162 | 163 | for _, name := range stackNames { 164 | dabFile := fmt.Sprintf("%s.dab", name) 165 | defs = append(defs, stackDefinition{name: name, file: dabFile}) 166 | } 167 | } else if len(stackNames) > 0 { 168 | for _, name := range stackNames { 169 | dabFile := fmt.Sprintf("%s.dab", name) 170 | defs = append(defs, stackDefinition{name: name, file: dabFile}) 171 | } 172 | } 173 | 174 | stacks := make([]Stack, len(defs)) 175 | for i, def := range defs { 176 | var dabReader io.Reader 177 | if u, e := url.Parse(def.file); e == nil && u.IsAbs() { 178 | // DAB file seems to be remote, try to download it first 179 | return nil, cli.NewExitError("Not implemented", 2) 180 | } else { 181 | if file, err := os.Open(def.file); err != nil { 182 | return nil, cli.NewExitError(err.Error(), 3) 183 | } else { 184 | dabReader = file 185 | } 186 | } 187 | 188 | bundle, bundleErr := bundlefile.LoadFile(dabReader) 189 | if bundleErr != nil { 190 | return nil, cli.NewExitError(bundleErr.Error(), 3) 191 | } 192 | stacks[i] = Stack{Name: def.name, Bundle: bundle} 193 | } 194 | return stacks, nil 195 | } 196 | --------------------------------------------------------------------------------