diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db0bbe6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +.cache +bin +vendor +package-lock.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..67db858 --- /dev/null +++ b/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/MAINTAINERS b/MAINTAINERS new file mode 100644 index 0000000..0d48a65 --- /dev/null +++ b/MAINTAINERS @@ -0,0 +1 @@ +Lucas Käldström (@luxas) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..67e26a0 --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +PROJECT=github.com/luxas/workshopctl +GO_VERSION=1.13.1 +BINARIES=workshopctl +CACHE_DIR = $(shell pwd)/bin/cache + +all: build +build: $(BINARIES) + +.PHONY: $(BINARIES) +$(BINARIES): + make shell COMMAND="make bin/$@" + +.PHONY: bin/workshopctl +bin/workshopctl: bin/%: vendor node_modules + CGO_ENABLED=0 go build -mod=vendor -ldflags "$(shell ./hack/ldflags.sh)" -o bin/$* ./cmd/$* + +shell: + mkdir -p $(CACHE_DIR)/go $(CACHE_DIR)/cache + docker run -it --rm \ + -v $(CACHE_DIR)/go:/go \ + -v $(CACHE_DIR)/cache:/.cache/go-build \ + -v $(shell pwd):/go/src/${PROJECT} \ + -w /go/src/${PROJECT} \ + -u $(shell id -u):$(shell id -g) \ + golang:$(GO_VERSION) \ + $(COMMAND) + +vendor: + go mod tidy + go mod vendor + +node_modules: + docker run -it -v $(pwd):/project -w /project node:slim npm install + +tidy: /go/bin/goimports + go mod tidy + go mod vendor + gofmt -s -w pkg cmd + goimports -w pkg cmd + go run hack/cobra.go + +/go/bin/goimports: + go get golang.org/x/tools/cmd/goimports diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6735cc --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# workshopctl + +A tool for running workshops easily in the cloud! + +## Quick Start + +1. `workshopctl init` -- Give information about what cloud provider to use (and its token), + and what domain to serve on (e.g. `workshopctl.kubernetesfinland.com`) +1. `workshopctl gen --clusters 40` -- Generate 40 unique sets of Kubernetes manifests, one per cluster. +1. `workshopctl apply` -- Creates the clusters in the cloud, and applies the manifests + +Boom! A Visual Studio Code instance running in the browser is now available at [cluster-01.workshopctl.kubernetesfinland.com](https://cluster-01.workshopctl.kubernetesfinland.com). +The VS Code terminal has full privileges to the Kubernetes cluster, so the attendee may easily +access `kubectl`, `helm` and `docker` (if needed) for completing the tasks in your workshop. +You can also provide pre-created materials in VS Code for the attendee. + +## How this works + +`workshop gen` generates unique manifests for any number of workshop clusters +you need. The base unit for writing the manifests is [Helm](https://helm.sh/) Charts, but with a +twist. We're using [jkcfg](https://jkcfg.github.io/#/) ("Javascript Kubernetes", configuration as +code) to both preprocess the `values.yaml` file, and the output from Helm. + +In other words the flow looks like this: + +`workshopctl gen` -> `jk run values.js` -> `helm template` -> `jk run pipe.js` -> `clusters/XX/manifest.yaml` + +```txt +`workshopctl gen`: from 1 -> {clusters (e.g. 50)}, do: +--> Find Helm chart in manifests//chart +---> Preprocess `values.yaml` using `jk run values.js` +----> Run `helm template` for the given `values.yaml` +-----> Pipe the content into `jk run pipe.js`, which patches the Helm output on the fly +------> Save content to cluster/$i/.yaml +``` diff --git a/cmd/workshopctl/cmd/apply.go b/cmd/workshopctl/cmd/apply.go new file mode 100644 index 0000000..ee350fb --- /dev/null +++ b/cmd/workshopctl/cmd/apply.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "fmt" + "io" + + "github.com/luxas/workshopctl/pkg/config" + "github.com/luxas/workshopctl/pkg/provider" + "github.com/luxas/workshopctl/pkg/provider/digitalocean" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var dryrun = true + +// NewApplyCommand returns the "apply" command +func NewApplyCommand(in io.Reader, out, err io.Writer) *cobra.Command { + cfg := &config.Config{} + cmd := &cobra.Command{ + Use: "apply", + Short: "Create a Kubernetes cluster and apply the desired manifests", + Run: RunApply(cfg), + } + + addApplyFlags(cmd.Flags(), cfg) + return cmd +} + +func addApplyFlags(fs *pflag.FlagSet, cfg *config.Config) { + addGenFlags(fs, cfg) + fs.BoolVar(&dryrun, "dry-run", dryrun, "Whether to dry-run or not") + fs.Uint16Var(&cfg.CPUs, "node-cpus", 2, "How much CPUs to use per-node") + fs.Uint16Var(&cfg.RAM, "node-ram", 2, "How much RAM to use per-node") + fs.Uint16Var(&cfg.NodeCount, "node-count", 1, "How many nodes per cluster") + fs.StringVar(&cfg.ServiceAccount, "service-account", "", "What serviceaccount/token to use. Can be a string or a file") +} + +func RunApply(cfg *config.Config) func(cmd *cobra.Command, args []string) { + return func(cmd *cobra.Command, args []string) { + err := func() error { + if cfg.Provider != "digitalocean" { + return fmt.Errorf("no other providers but DO supported at the moment") + } + if len(cfg.ServiceAccount) == 0 { + return fmt.Errorf("a serviceaccount is required") + } + sa := provider.NewServiceAccount(cfg.ServiceAccount) + p := digitalocean.NewDigitalOceanProvider(sa, dryrun) + i := uint16(1) + cluster, err := p.CreateCluster(i, provider.ClusterSpec{ + Name: fmt.Sprintf("workshopctl-cluster-%d", i), + NodeSize: provider.NodeSize{ + CPUs: cfg.CPUs, + RAM: cfg.RAM, + }, + NodeCount: cfg.Clusters, + Version: "latest", + }) + if err != nil { + return err + } + fmt.Println(*cluster) + return nil + }() + if err != nil { + log.Fatal(err) + } + } +} diff --git a/cmd/workshopctl/cmd/gen.go b/cmd/workshopctl/cmd/gen.go new file mode 100644 index 0000000..0bfddc1 --- /dev/null +++ b/cmd/workshopctl/cmd/gen.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "io" + "io/ioutil" + "os" + "path/filepath" + + "github.com/luxas/workshopctl/pkg/config" + "github.com/luxas/workshopctl/pkg/gen" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// NewGenCommand returns the "gen" command +func NewGenCommand(in io.Reader, out, err io.Writer) *cobra.Command { + cfg := &config.Config{} + cmd := &cobra.Command{ + Use: "gen", + Short: "Generate a set of manifests based on the configuration", + Run: RunGen(cfg), + } + + addGenFlags(cmd.Flags(), cfg) + return cmd +} + +func addGenFlags(fs *pflag.FlagSet, cfg *config.Config) { + fs.StringVar(&cfg.Provider, "provider", "digitalocean", "What provider to use") + fs.Uint16VarP(&cfg.Clusters, "clusters", "c", 1, "How many clusters to create") + fs.StringVarP(&cfg.Domain, "domain", "d", "workshopctl.kubernetesfinland.com", "What domain to use") + fs.StringVarP(&cfg.GitRepo, "git-repo", "r", "https://github.com/luxas/workshopctl", "What git repo to use") + fs.StringVar(&cfg.RootDir, "root-dir", ".", "Where the workshopctl directory is") +} + +func RunGen(cfg *config.Config) func(cmd *cobra.Command, args []string) { + return func(cmd *cobra.Command, args []string) { + err := func() error { + // Resolve relative paths to absolute ones + if !filepath.IsAbs(cfg.RootDir) { + pwd, _ := os.Getwd() + cfg.RootDir = filepath.Join(pwd, cfg.RootDir) + } + manifestDir := filepath.Join(cfg.RootDir, "manifests") + chartInfos, err := ioutil.ReadDir(manifestDir) + if err != nil { + return err + } + charts := make([]*gen.ChartData, 0, len(chartInfos)) + for _, chartInfo := range chartInfos { + if !chartInfo.IsDir() { + continue + } + chart, err := gen.SetupChartCache(cfg.RootDir, chartInfo.Name()) + if err != nil { + return err + } + charts = append(charts, chart) + } + + for i := uint16(1); i <= cfg.Clusters; i++ { + log.Infof("Cluster %d...", i) + for _, chart := range charts { + log.Infof(" Generating chart %q...", chart.Name) + if err := gen.GenerateChart(chart, i, cfg); err != nil { + return err + } + } + } + return nil + }() + if err != nil { + log.Fatal(err) + } + } +} diff --git a/cmd/workshopctl/cmd/root.go b/cmd/workshopctl/cmd/root.go new file mode 100644 index 0000000..eeb3814 --- /dev/null +++ b/cmd/workshopctl/cmd/root.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "io" + "os" + + "github.com/luxas/workshopctl/pkg/logs" + logflag "github.com/luxas/workshopctl/pkg/logs/flag" + versioncmd "github.com/luxas/workshopctl/pkg/version/cmd" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var logLevel = logrus.InfoLevel + +// NewIgniteCommand returns the root command for ignite +func NewWorkshopCtlCommand(in io.Reader, out, err io.Writer) *cobra.Command { + + root := &cobra.Command{ + Use: "workshopctl", + Short: "workshopctl: easily run Kubernetes workshops", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Set the desired logging level, now that the flags are parsed + logs.Logger.SetLevel(logLevel) + }, + } + + addGlobalFlags(root.PersistentFlags()) + + root.AddCommand(NewInitCommand(os.Stdin, os.Stdout, os.Stderr)) + root.AddCommand(NewGenCommand(os.Stdin, os.Stdout, os.Stderr)) + root.AddCommand(NewApplyCommand(os.Stdin, os.Stdout, os.Stderr)) + root.AddCommand(NewKubectlCommand(os.Stdin, os.Stdout, os.Stderr)) + root.AddCommand(versioncmd.NewCmdVersion(os.Stdout)) + return root +} + +func addGlobalFlags(fs *pflag.FlagSet) { + logflag.LogLevelFlagVar(fs, &logLevel) +} diff --git a/cmd/workshopctl/main.go b/cmd/workshopctl/main.go new file mode 100644 index 0000000..1b1d245 --- /dev/null +++ b/cmd/workshopctl/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "os" + + "github.com/luxas/workshopctl/cmd/workshopctl/cmd" +) + +func main() { + if err := Run(); err != nil { + os.Exit(1) + } +} + +// Run runs the main cobra command of this application +func Run() error { + c := cmd.NewWorkshopCtlCommand(os.Stdin, os.Stdout, os.Stderr) + return c.Execute() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..013c83f --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/luxas/workshopctl + +go 1.12 + +require ( + github.com/digitalocean/godo v1.22.0 + github.com/golang/protobuf v1.2.0 + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/otiai10/copy v1.0.2 + github.com/otiai10/curr v0.0.0-20190513014714-f5a3d24e5776 // indirect + github.com/russross/blackfriday v1.5.2 + github.com/sirupsen/logrus v1.4.2 + github.com/spf13/cobra v0.0.5 + github.com/spf13/pflag v1.0.5 + golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e + golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a + golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3 // indirect + google.golang.org/appengine v1.4.0 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect + gopkg.in/yaml.v2 v2.2.4 + sigs.k8s.io/yaml v1.1.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ef35618 --- /dev/null +++ b/go.sum @@ -0,0 +1,93 @@ +bou.ke/monkey v1.0.1/go.mod h1:FgHuK96Rv2Nlf+0u1OOVDpCMdsWyOFmeeketDHE7LIg= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/digitalocean/godo v1.22.0 h1:bVFBKXW2TlynZ9SqmlM6ZSW6UPEzFckltSIUT5NC8L4= +github.com/digitalocean/godo v1.22.0/go.mod h1:iJnN9rVu6K5LioLxLimlq0uRI+y/eAQjROUmeU/r0hY= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/otiai10/copy v1.0.2 h1:DDNipYy6RkIkjMwy+AWzgKiNTyj2RUI9yEMeETEpVyc= +github.com/otiai10/copy v1.0.2/go.mod h1:c7RpqBkwMom4bYTSkLSym4VSJz/XtncWRAj/J4PEIMY= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v0.0.0-20190513014714-f5a3d24e5776 h1:o59bHXu8Ejas8Kq6pjoVJQ9/neN66SM8AKh6wI42BBs= +github.com/otiai10/curr v0.0.0-20190513014714-f5a3d24e5776/go.mod h1:3HNVkVOU7vZeFXocWuvtcS0XSFLcf2XUSDHkq9t1jU4= +github.com/otiai10/mint v1.2.4/go.mod h1:d+b7n/0R3tdyUYYylALXpWQ/kTN+QobSq/4SRGBkR3M= +github.com/otiai10/mint v1.3.0 h1:Ady6MKVezQwHBkGzLFbrsywyp09Ah7rkmfjV3Bcr5uc= +github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a h1:tImsplftrFpALCYumobsd0K86vlAs/eXGFms2txfJfA= +golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3 h1:4y9KwBHBgBNwDbtu44R5o1fdOCQUEXhbk/P4A9WmJq0= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= diff --git a/hack/cobra.go b/hack/cobra.go new file mode 100644 index 0000000..c3270ce --- /dev/null +++ b/hack/cobra.go @@ -0,0 +1,21 @@ +package main + +import ( + "log" + "os" + "os/exec" + + "github.com/spf13/cobra/doc" + "github.com/luxas/workshopctl/cmd/workshopctl/cmd" +) + +func main() { + command := cmd.NewWorkshopCtlCommand(os.Stdin, os.Stdout, os.Stderr) + if err := doc.GenMarkdownTree(command, "./docs/cli"); err != nil { + log.Fatal(err) + } + sedCmd := `sed -e "/Auto generated/d" -i docs/cli/*.md` + if output, err := exec.Command("/bin/bash", "-c", sedCmd).CombinedOutput(); err != nil { + log.Fatal(string(output), err) + } +} diff --git a/hack/ldflags.sh b/hack/ldflags.sh new file mode 100755 index 0000000..e7c173e --- /dev/null +++ b/hack/ldflags.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +# Note: This file is heavily inspired by https://github.com/kubernetes/kubernetes/blob/master/hack/lib/version.sh + +get_version_vars() { + GIT_COMMIT=$(git rev-parse "HEAD^{commit}" 2>/dev/null) + if git_status=$(git status --porcelain 2>/dev/null) && [[ -z ${git_status} ]]; then + GIT_TREE_STATE="clean" + else + GIT_TREE_STATE="dirty" + fi + # Use git describe to find the version based on tags. + GIT_VERSION=$(git describe --tags --abbrev=14 "${GIT_COMMIT}^{commit}" 2>/dev/null) + + # This translates the "git describe" to an actual semver.org + # compatible semantic version that looks something like this: + # v1.1.0-alpha.0.6+84c76d1142ea4d + DASHES_IN_VERSION=$(echo "${GIT_VERSION}" | sed "s/[^-]//g") + if [[ "${DASHES_IN_VERSION}" == "---" ]] ; then + # We have distance to subversion (v1.1.0-subversion-1-gCommitHash) + GIT_VERSION=$(echo "${GIT_VERSION}" | sed "s/-\([0-9]\{1,\}\)-g\([0-9a-f]\{14\}\)$/.\1\+\2/") + elif [[ "${DASHES_IN_VERSION}" == "--" ]] ; then + # We have distance to base tag (v1.1.0-1-gCommitHash) + GIT_VERSION=$(echo "${GIT_VERSION}" | sed "s/-g\([0-9a-f]\{14\}\)$/+\1/") + fi + if [[ "${GIT_TREE_STATE}" == "dirty" ]]; then + # git describe --dirty only considers changes to existing files, but + # that is problematic since new untracked .go files affect the build, + # so use our idea of "dirty" from git status instead. + GIT_VERSION+="-dirty" + fi + + # Try to match the "git describe" output to a regex to try to extract + # the "major" and "minor" versions and whether this is the exact tagged + # version or whether the tree is between two tagged versions. + if [[ "${GIT_VERSION}" =~ ^v([0-9]+)\.([0-9]+)(\.[0-9]+)?([-].*)?([+].*)?$ ]]; then + GIT_MAJOR=${BASH_REMATCH[1]} + GIT_MINOR=${BASH_REMATCH[2]} + if [[ -n "${BASH_REMATCH[4]}" ]]; then + GIT_MINOR+="+" + fi + fi +} + +ldflag() { + local key=${1} + local val=${2} + echo "-X 'github.com/luxas/workshopctl/pkg/version.${key}=${val}'" +} + +# Prints the value that needs to be passed to the -ldflags parameter of go build +# in order to set the Ignite version based on the git tree status. +ldflags() { + get_version_vars + + local buildDate= + [[ -z ${SOURCE_DATE_EPOCH-} ]] || buildDate="--date=@${SOURCE_DATE_EPOCH}" + local -a ldflags=($(ldflag "buildDate" "$(date ${buildDate} -u +'%Y-%m-%dT%H:%M:%SZ')")) + if [[ -n ${GIT_COMMIT-} ]]; then + ldflags+=($(ldflag "gitCommit" "${GIT_COMMIT}")) + ldflags+=($(ldflag "gitTreeState" "${GIT_TREE_STATE}")) + fi + + if [[ -n ${GIT_VERSION-} ]]; then + ldflags+=($(ldflag "gitVersion" "${GIT_VERSION}")) + fi + + if [[ -n ${GIT_MAJOR-} && -n ${GIT_MINOR-} ]]; then + ldflags+=( + $(ldflag "gitMajor" "${GIT_MAJOR}") + $(ldflag "gitMinor" "${GIT_MINOR}") + ) + fi + + # Output only the version with this flag + if [[ $1 == "--version-only" ]]; then + echo "${GIT_VERSION}" + exit 0 + elif [[ $1 == "--image-tag-only" ]]; then + echo "${GIT_VERSION}" | sed "s/+/-/g" + exit 0 + fi + + # The -ldflags parameter takes a single string, so join the output. + echo "${ldflags[*]-}" +} + +ldflags $@ \ No newline at end of file diff --git a/pkg/config/types.go b/pkg/config/types.go new file mode 100644 index 0000000..0bfff56 --- /dev/null +++ b/pkg/config/types.go @@ -0,0 +1,14 @@ +package config + +type Config struct { + Domain string `json:"domain"` + Clusters uint16 `json:"clusters"` + GitRepo string `json:"gitRepo"` + RootDir string `json:"-"` + + Provider string `json:"provider"` + ServiceAccount string `json:"serviceAccount"` + CPUs uint16 `json:"cpus"` + RAM uint16 `json:"ram"` + NodeCount uint16 `json:"nodeCount"` +} diff --git a/pkg/gen/gen.go b/pkg/gen/gen.go new file mode 100644 index 0000000..3384564 --- /dev/null +++ b/pkg/gen/gen.go @@ -0,0 +1,159 @@ +package gen + +import ( + "fmt" + "io/ioutil" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/luxas/workshopctl/pkg/config" + "github.com/luxas/workshopctl/pkg/util" + log "github.com/sirupsen/logrus" +) + +const ( + pipeJS = "pipe.js" + valuesJS = "values.js" + valuesYAML = "values.yaml" + chartDir = "chart" + cacheDir = ".cache" +) + +type ChartData struct { + Name string + ManifestDir string + CacheDir string + CopiedFiles map[string]struct{} +} + +func SetupChartCache(rootPath, chartName string) (*ChartData, error) { + cd := &ChartData{ + CacheDir: filepath.Join(rootPath, cacheDir, chartName), + ManifestDir: filepath.Join(rootPath, "manifests", chartName), + Name: chartName, + CopiedFiles: map[string]struct{}{}, + } + + // Create the .cache directory for the chart + if err := os.MkdirAll(cd.CacheDir, 0755); err != nil { + return nil, err + } + + // Create the node_modules symlink if it doesn't exist + log.Debugf("Symlinking node_modules...") + nmPath := filepath.Join(cd.CacheDir, "node_modules") + if exists, _ := util.PathExists(nmPath); !exists { + if err := os.Symlink(filepath.Join(rootPath, "node_modules"), nmPath); err != nil { + return nil, err + } + } + + // Download the chart if it doesn't exist to chartPath + chartPath := filepath.Join(cd.CacheDir, "chart") + externalChartFile := filepath.Join(cd.ManifestDir, "external-chart") + if util.FileExists(externalChartFile) { + b, err := ioutil.ReadFile(externalChartFile) + if err != nil { + return nil, err + } + externalChart := string(b) + + u, err := url.Parse(externalChart) + if err != nil { + return nil, err + } + if len(u.Scheme) > 0 { + // Remove the last path element from the URL; that's the chartName + cname := filepath.Base(u.Path) + u.Path = filepath.Dir(u.Path) + crepo := strings.ReplaceAll(u.Host, ".", "-") + externalChart = fmt.Sprintf("%s/%s", crepo, cname) + + out, err := util.ExecuteCommand("helm", "repo", "list") + if err != nil { + return nil, err + } + // Only add the repo if it doesn't already exist + if !strings.Contains(out, crepo) { + log.Infof("Adding a new helm repo called %q pointing to %q", crepo, u.String()) + _, err = util.ExecuteCommand("helm", "repo", "add", crepo, u.String()) + if err != nil { + return nil, err + } + } + } else { + arr := strings.Split(externalChart, "/") + if len(arr) != 2 { + return nil, fmt.Errorf("invalid format of %q: %q. Should be either {stable,test}/{name} or {repo-url}/{name}", externalChartFile, externalChart) + } + } + + log.Infof("Found external chart to download %q", externalChart) + // this extracts the chart to e.g. .cache/kubernetes-dashboard/kubernetes-dashboard + // although it should be .cache/kubernetes-dashboard/chart + _, err = util.ExecuteCommand("helm", "fetch", externalChart, "--untar", "--untardir", cd.CacheDir) + if err != nil { + return nil, err + } + + // Remove chartPath if it already exists + if err := os.RemoveAll(chartPath); err != nil { + return nil, err + } + + // as described above, e.g. .cache/kubernetes-dashboard/kubernetes-dashboard + wrongPath := filepath.Join(cd.CacheDir, filepath.Base(externalChart)) + // make the path right + log.Debugf("Renaming %q to %q", wrongPath, chartPath) + if err := os.Rename(wrongPath, chartPath); err != nil { + return nil, err + } + } + + for _, f := range []string{pipeJS, valuesJS, valuesYAML, chartDir} { + from := filepath.Join(cd.ManifestDir, f) + to := filepath.Join(cd.CacheDir, f) + if exists, _ := util.PathExists(from); exists { + cd.CopiedFiles[f] = struct{}{} + if err := util.Copy(from, to); err != nil { + return nil, err + } + } + } + return cd, nil +} + +func GenerateChart(cd *ChartData, i uint16, cfg *config.Config) error { + pipeJSPath := pipeJS + if _, ok := cd.CopiedFiles[pipeJS]; !ok { + pipeJSPath = "../../jkcfg/default-pipe.js" + } + valuesJSPath := valuesJS + if _, ok := cd.CopiedFiles[valuesJS]; !ok { + valuesJSPath = "../../jkcfg/default-values.js" + } + valuesArgMap := map[string]string{ + "cluster-number": fmt.Sprintf(`"%02d"`, i), + "domain": cfg.Domain, + "git-repo": cfg.GitRepo, + "provider": cfg.Provider, + } + valuesArgStr := "" + for k, v := range valuesArgMap { + valuesArgStr += fmt.Sprintf("-p=%s=%s ", k, v) + } + + cmd := fmt.Sprintf("cd %s && jk run %s %s | helm template workshopctl chart -f - | jk run %s", cd.CacheDir, valuesArgStr, valuesJSPath, pipeJSPath) + content, err := util.ExecuteCommand("/bin/bash", "-c", cmd) + if err != nil { + return err + } + + outputFile := filepath.Join(cfg.RootDir, "clusters", fmt.Sprintf("%02d", i), fmt.Sprintf("%s.yaml", cd.Name)) + if err := os.MkdirAll(filepath.Dir(outputFile), 0755); err != nil { + return err + } + return ioutil.WriteFile(outputFile, []byte(content), 0644) +} diff --git a/pkg/logs/flag/flag.go b/pkg/logs/flag/flag.go new file mode 100644 index 0000000..3c226cf --- /dev/null +++ b/pkg/logs/flag/flag.go @@ -0,0 +1,33 @@ +package flag + +import ( + "github.com/sirupsen/logrus" + "github.com/spf13/pflag" +) + +type LogLevelFlag struct { + value *logrus.Level +} + +func (lf *LogLevelFlag) Set(val string) error { + var err error + *lf.value, err = logrus.ParseLevel(val) + return err +} + +func (lf *LogLevelFlag) String() string { + if lf.value == nil { + return "" + } + return lf.value.String() +} + +func (lf *LogLevelFlag) Type() string { + return "loglevel" +} + +var _ pflag.Value = &LogLevelFlag{} + +func LogLevelFlagVar(fs *pflag.FlagSet, ptr *logrus.Level) { + fs.Var(&LogLevelFlag{value: ptr}, "log-level", "Specify the loglevel for the program") +} diff --git a/pkg/logs/logs.go b/pkg/logs/logs.go new file mode 100644 index 0000000..e9dfd92 --- /dev/null +++ b/pkg/logs/logs.go @@ -0,0 +1,48 @@ +package logs + +import ( + golog "log" + "os" + + log "github.com/sirupsen/logrus" +) + +// Wrap the logrus logger together with the exit code +// so we can control what log.Fatal returns +type logger struct { + *log.Logger + ExitCode int +} + +func newLogger() *logger { + l := &logger{ + Logger: log.StandardLogger(), // Use the standard logrus logger + ExitCode: 1, + } + + l.ExitFunc = func(_ int) { + os.Exit(l.ExitCode) + } + + return l +} + +// Expose the logger +var Logger *logger + +// Automatically initialize the logging system for Ignite +func init() { + // Initialize the logger + Logger = newLogger() + + // Disable timestamp logging, but still output the seconds elapsed + Logger.SetFormatter(&log.TextFormatter{ + DisableTimestamp: false, + FullTimestamp: false, + }) + + // Disable the stdlib's automatic add of the timestamp in beginning of the log message, + // as we stream the logs from stdlib log to this logrus instance. + golog.SetFlags(0) + golog.SetOutput(Logger.Writer()) +} diff --git a/pkg/provider/digitalocean/do.go b/pkg/provider/digitalocean/do.go new file mode 100644 index 0000000..de718b3 --- /dev/null +++ b/pkg/provider/digitalocean/do.go @@ -0,0 +1,129 @@ +package digitalocean + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/url" + "time" + + "github.com/digitalocean/godo" + "github.com/luxas/workshopctl/pkg/provider" + log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" +) + +func NewDigitalOceanProvider(sa *provider.ServiceAccount, dryRun bool) provider.Provider { + p := &DigitalOceanProvider{ + sa: sa, + dryRun: dryRun, + } + oauthClient := oauth2.NewClient(context.Background(), sa) + p.c = godo.NewClient(oauthClient) + return p +} + +type DigitalOceanProvider struct { + sa *provider.ServiceAccount + c *godo.Client + dryRun bool +} + +func chooseSize(s provider.NodeSize) string { + m := map[provider.NodeSize]string{ + provider.NodeSize{CPUs: 1, RAM: 2}: "s-1vcpu-2gb", + provider.NodeSize{CPUs: 1, RAM: 3}: "s-1vcpu-3gb", + provider.NodeSize{CPUs: 2, RAM: 2}: "s-2vcpu-2gb", + provider.NodeSize{CPUs: 2, RAM: 4}: "s-2vcpu-4gb", + provider.NodeSize{CPUs: 4, RAM: 8}: "s-4vcpu-8gb", + provider.NodeSize{CPUs: 6, RAM: 16}: "s-6vcpu-16gb", + } + if str, ok := m[s]; ok { + return str + } + log.Warnf("didn't find a good size for you, fallback to s-1vcpu-2gb") + return "s-1vcpu-2gb" +} + +func (do *DigitalOceanProvider) CreateCluster(index uint16, c provider.ClusterSpec) (*provider.Cluster, error) { + start := time.Now().UTC() + cluster := &provider.Cluster{ + Spec: c, + Status: provider.ClusterStatus{ + ProvisionStart: &start, + }, + } + + nodePoolName := fmt.Sprintf("workshopctl-nodepool-%d", index) + nodePool := []*godo.KubernetesNodePoolCreateRequest{ + { + Name: nodePoolName, + Size: chooseSize(c.NodeSize), + Count: int(c.NodeCount), + AutoScale: false, + Tags: []string{ + "workshopctl", + nodePoolName, + }, + }, + } + + req := &godo.KubernetesClusterCreateRequest{ + Name: c.Name, + RegionSlug: "fra1", + VersionSlug: c.Version, // TODO: Resolve c.Version correctly + Tags: []string{ + "workshopctl", + }, + NodePools: nodePool, + AutoUpgrade: false, + } + + if do.dryRun || log.GetLevel() == log.DebugLevel { + b, _ := json.Marshal(req) + if do.dryRun { + log.Infof("Would send this request to DO: %s", string(b)) + return nil, nil + } + log.Debugf("Would send this request to DO: %s", string(b)) + } + + doCluster, _, err := do.c.Kubernetes.Create(context.Background(), req) + if err != nil { + return nil, err + } + cluster.Status.ID = doCluster.ID + u, err := url.Parse(doCluster.Endpoint) + if err != nil { + return nil, err + } + cluster.Status.EndpointURL = u + cluster.Status.EndpointIP = net.ParseIP(doCluster.IPv4) + if log.GetLevel() == log.DebugLevel { + b, _ := json.Marshal(cluster.Status) + log.Debugf("Got a response from DO: %s", string(b)) + } + + for { + time.Sleep(10 * time.Second) + kcluster, _, err := do.c.Kubernetes.Get(context.Background(), cluster.Status.ID) + if err != nil { + log.Errorf("getting a kubernetes cluster failed: %v", err) + continue + } + if kcluster.Status.State == godo.KubernetesClusterStatusRunning { + log.Infof("Awesome! We're done! message: %q", kcluster.Status.Message) + break + } + switch kcluster.Status.State { + case godo.KubernetesClusterStatusProvisioning: + log.Infof("cluster still provisioning! message: %q", kcluster.Status.Message) + continue + default: + log.Warnf("unknown state %q! message: %q", kcluster.Status.State, kcluster.Status.Message) + } + } + + return cluster, nil +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go new file mode 100644 index 0000000..fb1896e --- /dev/null +++ b/pkg/provider/provider.go @@ -0,0 +1,74 @@ +package provider + +import ( + "io/ioutil" + "net" + "net/url" + "time" + + "github.com/luxas/workshopctl/pkg/util" + "golang.org/x/oauth2" +) + +func NewServiceAccount(pathOrToken string) *ServiceAccount { + if util.FileExists(pathOrToken) { + return &ServiceAccount{ + path: pathOrToken, + } + } + return &ServiceAccount{ + token: pathOrToken, + } +} + +type ServiceAccount struct { + token, path string +} + +func (sa *ServiceAccount) Token() (*oauth2.Token, error) { + t, err := sa.Get() + if err != nil { + return nil, err + } + return &oauth2.Token{ + AccessToken: t, + }, nil +} + +func (sa *ServiceAccount) Get() (string, error) { + if sa.token != "" { + return sa.token, nil + } + b, err := ioutil.ReadFile(sa.path) + return string(b), err +} + +type NodeSize struct { + CPUs uint16 + RAM uint16 +} + +type Cluster struct { + Spec ClusterSpec + Status ClusterStatus +} + +type ClusterSpec struct { + NodeSize NodeSize + NodeCount uint16 + Name string + Version string +} + +type ClusterStatus struct { + ID string + ProvisionStart *time.Time + ProvisionDone *time.Time + EndpointURL *url.URL + EndpointIP net.IP +} + +type Provider interface { + // CreateCluster creates a cluster. This call is _blocking_ until the cluster is properly provisioned + CreateCluster(index uint16, c ClusterSpec) (*Cluster, error) +} diff --git a/pkg/util/util.go b/pkg/util/util.go new file mode 100644 index 0000000..3a92fbd --- /dev/null +++ b/pkg/util/util.go @@ -0,0 +1,48 @@ +package util + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/otiai10/copy" + log "github.com/sirupsen/logrus" +) + +func PathExists(path string) (bool, os.FileInfo) { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return false, nil + } + + return true, info +} + +func FileExists(filename string) bool { + exists, info := PathExists(filename) + if !exists { + return false + } + + return !info.IsDir() +} + +// Copy copies both files and directories +func Copy(src string, dst string) error { + log.Debugf("Copying %q to %q", src, dst) + return copy.Copy(src, dst) +} + +func ExecuteCommand(command string, args ...string) (string, error) { + log.Debugf(`Executing "%s %s"`, command, strings.Join(args, " ")) + cmd := exec.Command(command, args...) + out, err := cmd.CombinedOutput() + if err != nil { + log.Debugf(`Returned error: %v and output: %q`, err, out) + return "", fmt.Errorf("command %q exited with %q: %v", cmd.Args, out, err) + } + + return string(bytes.TrimSpace(out)), nil +} diff --git a/pkg/version/cmd/command.go b/pkg/version/cmd/command.go new file mode 100644 index 0000000..331413f --- /dev/null +++ b/pkg/version/cmd/command.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/luxas/workshopctl/pkg/version" + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" +) + +// NewCmdVersion provides the version information of ignite +func NewCmdVersion(out io.Writer) *cobra.Command { + var output string + cmd := &cobra.Command{ + Use: "version", + Short: "Print the version", + RunE: func(cmd *cobra.Command, args []string) error { + return RunVersion(out, output) + }, + } + + cmd.Flags().StringVarP(&output, "output", "o", output, "Output format; available options are 'yaml', 'json' and 'short'") + return cmd +} + +// RunVersion provides the version information for the specified format +func RunVersion(out io.Writer, output string) error { + v := version.Get() + switch output { + case "": + fmt.Fprintf(out, "Version: %#v\n", v) + case "short": + fmt.Fprintf(out, "%s\n", v) + case "yaml": + y, err := yaml.Marshal(&v) + if err != nil { + return err + } + fmt.Fprintln(out, string(y)) + case "json": + y, err := json.MarshalIndent(&v, "", " ") + if err != nil { + return err + } + fmt.Fprintln(out, string(y)) + default: + return fmt.Errorf("invalid output format: %s", output) + } + + return nil +} diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 0000000..0b7541d --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,48 @@ +package version + +import ( + "fmt" + "runtime" +) + +var ( + gitMajor = "" + gitMinor = "" + gitVersion = "" + gitCommit = "" + gitTreeState = "" + buildDate = "" +) + +// Info stores information about a component's version +type Info struct { + Major string `json:"major"` + Minor string `json:"minor"` + GitVersion string `json:"gitVersion"` + GitCommit string `json:"gitCommit"` + GitTreeState string `json:"gitTreeState"` + BuildDate string `json:"buildDate"` + GoVersion string `json:"goVersion"` + Compiler string `json:"compiler"` + Platform string `json:"platform"` +} + +// String returns info as a human-friendly version string. +func (info Info) String() string { + return info.GitVersion +} + +// Get gets the version +func Get() Info { + return Info{ + Major: gitMajor, + Minor: gitMinor, + GitVersion: gitVersion, + GitCommit: gitCommit, + GitTreeState: gitTreeState, + BuildDate: buildDate, + GoVersion: runtime.Version(), + Compiler: runtime.Compiler, + Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + } +}