mirror of
https://github.com/foomo/squadron.git
synced 2025-10-16 12:35:42 +00:00
519 lines
14 KiB
Go
519 lines
14 KiB
Go
package squadron
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/foomo/squadron/exampledata"
|
|
"github.com/foomo/squadron/util"
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"gopkg.in/yaml.v2"
|
|
)
|
|
|
|
const (
|
|
defaultConfigFileExt = ".yml"
|
|
defaultServiceDir = "squadron/services"
|
|
defaultNamespaceDir = "squadron/namespaces"
|
|
defaultOutputDir = "squadron/.workdir"
|
|
chartsDir = "charts"
|
|
chartLockFile = "Chart.lock"
|
|
chartFile = "Chart.yaml"
|
|
valuesFile = "values.yaml"
|
|
requirementsFile = "requirements.yaml"
|
|
chartApiVersionV2 = "v2"
|
|
chartApiVersionV1 = "v1"
|
|
defaultChartType = "application" // application or library
|
|
defaultChartVersion = "0.2.0"
|
|
defaultChartAppVersion = "1.16.0"
|
|
defaultHelmRepo = "https://kubernetes-charts.storage.googleapis.com/"
|
|
)
|
|
|
|
var (
|
|
ErrServiceNotFound = errors.New("service not found")
|
|
ErrBuildNotConfigured = errors.New("build command was not configured")
|
|
)
|
|
|
|
type Override map[string]interface{}
|
|
|
|
type Group struct {
|
|
Name string `yaml:"-"`
|
|
Version string
|
|
ServiceOverrides map[string]Override `yaml:"services"`
|
|
JobOverrides map[string]Override `yaml:"jobs"`
|
|
}
|
|
|
|
func (g Group) Services() []string {
|
|
var services []string
|
|
for service := range g.ServiceOverrides {
|
|
services = append(services, service)
|
|
}
|
|
return services
|
|
}
|
|
|
|
type Namespace struct {
|
|
name string
|
|
groups []string
|
|
}
|
|
|
|
type Config struct {
|
|
Tag string
|
|
BasePath string
|
|
Log *logrus.Entry
|
|
}
|
|
|
|
type Build struct {
|
|
Image string
|
|
Tag string
|
|
Command string
|
|
}
|
|
|
|
type Service struct {
|
|
Name string `yaml:"-"`
|
|
Build Build `yaml:"build"`
|
|
Chart ChartDependency `yaml:"chart"`
|
|
}
|
|
type ChartDependency struct {
|
|
Name string
|
|
Repository string
|
|
Version string
|
|
Alias string
|
|
}
|
|
|
|
func (cd *ChartDependency) validate(basePath, service string) error {
|
|
if cd.Name == "" {
|
|
return fmt.Errorf("service %q chart field %q required", service, "name")
|
|
}
|
|
if cd.Version == "" {
|
|
return fmt.Errorf("service %q chart field %q required", service, "version")
|
|
}
|
|
if strings.HasPrefix(cd.Repository, "file://./") {
|
|
cd.Repository = strings.Replace(cd.Repository, "file://./", fmt.Sprintf("file://%v/", basePath), 1)
|
|
}
|
|
if cd.Repository == "" {
|
|
cd.Repository = defaultHelmRepo
|
|
}
|
|
cd.Alias = service
|
|
return nil
|
|
}
|
|
|
|
type Chart struct {
|
|
APIVersion string `yaml:"apiVersion"`
|
|
Name string
|
|
Description string
|
|
Type string
|
|
Version string
|
|
Dependencies []ChartDependency `yaml:"dependencies,omitempty"`
|
|
}
|
|
|
|
func newChart(name, version string) *Chart {
|
|
return &Chart{
|
|
APIVersion: chartApiVersionV2,
|
|
Name: name,
|
|
Description: fmt.Sprintf("A helm parent chart for squadron %v", name),
|
|
Type: defaultChartType,
|
|
Version: version,
|
|
}
|
|
}
|
|
|
|
func (c Chart) generateChartFiles(chartPath string, overrides interface{}, useChartApiV1 bool) error {
|
|
if useChartApiV1 {
|
|
c.APIVersion = chartApiVersionV1
|
|
// for chart APIVersion v1 dependencies are defined outside Chart.yaml, in requirements.yaml
|
|
var wrapper struct {
|
|
Dependencies []ChartDependency
|
|
}
|
|
wrapper.Dependencies = c.Dependencies
|
|
c.Dependencies = nil
|
|
// generate requirements.yaml
|
|
if err := util.GenerateYaml(path.Join(chartPath, requirementsFile), wrapper); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// generate Chart.yaml
|
|
if err := util.GenerateYaml(path.Join(chartPath, chartFile), c); err != nil {
|
|
return err
|
|
}
|
|
// generate values.yaml with overrides
|
|
if err := util.GenerateYaml(path.Join(chartPath, valuesFile), overrides); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type JobItem struct {
|
|
Name string
|
|
Overrides interface{}
|
|
namespace string
|
|
group string
|
|
chart string
|
|
}
|
|
|
|
type serviceLoader func(string) (Service, error)
|
|
|
|
type TemplateVars map[string]interface{}
|
|
|
|
func (tv TemplateVars) supportedFileExt() []string {
|
|
return []string{"yml", "yaml", "json"}
|
|
}
|
|
|
|
func NewTemplateVars(workDir string, sourceSlice []string, sourceFile string) (TemplateVars, error) {
|
|
tv := TemplateVars{}
|
|
if err := tv.parseFile(workDir, sourceFile); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tv.parseSlice(sourceSlice); err != nil {
|
|
return nil, err
|
|
}
|
|
tv["cwd"] = workDir
|
|
return tv, nil
|
|
}
|
|
|
|
func (tv TemplateVars) parseSlice(source []string) error {
|
|
for _, item := range source {
|
|
pieces := strings.Split(item, "=")
|
|
if len(pieces) != 2 || pieces[0] == "" {
|
|
return fmt.Errorf("Invalid format for template var %q, use x=y", item)
|
|
}
|
|
tv[pieces[0]] = pieces[1]
|
|
}
|
|
return nil
|
|
}
|
|
func (tv TemplateVars) parseFile(workDir, source string) error {
|
|
if source == "" {
|
|
return nil
|
|
}
|
|
if !filepath.IsAbs(source) {
|
|
source = path.Join(workDir, source)
|
|
}
|
|
if !util.IsYaml(source) && !util.IsJson(source) {
|
|
return fmt.Errorf("Unable to parse %q, supported: %v", source, strings.Join(tv.supportedFileExt(), ", "))
|
|
}
|
|
file, err := ioutil.ReadFile(source)
|
|
if err != nil {
|
|
return fmt.Errorf("Error while opening template file: %s", err)
|
|
}
|
|
if util.IsYaml(source) {
|
|
if err := yaml.Unmarshal(file, &tv); err != nil {
|
|
return fmt.Errorf("Error while unmarshalling template file: %s", err)
|
|
}
|
|
}
|
|
if util.IsJson(source) {
|
|
if err := json.Unmarshal(file, &tv); err != nil {
|
|
return fmt.Errorf("Error while unmarshalling template file: %s", err)
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type Squadron struct {
|
|
l *logrus.Entry
|
|
basePath string
|
|
tag string
|
|
Services []Service
|
|
Templates []string
|
|
Namespaces []Namespace
|
|
helmCmd *util.HelmCmd
|
|
kubeCmd *util.KubeCmd
|
|
dockerCmd *util.DockerCmd
|
|
}
|
|
|
|
func New(l *logrus.Entry, tag, basePath, namespace string) (*Squadron, error) {
|
|
sq := Squadron{l: l, basePath: basePath, tag: tag}
|
|
sq.helmCmd = util.NewHelmCommand(l)
|
|
sq.kubeCmd = util.NewKubeCommand(l)
|
|
sq.dockerCmd = util.NewDockerCommand(l)
|
|
sq.helmCmd.Args("-n", namespace)
|
|
sq.kubeCmd.Args("-n", namespace)
|
|
|
|
l.Infof("Parsing configuration files")
|
|
l.Infof("Entering dir: %q", basePath)
|
|
serviceDir := path.Join(basePath, defaultServiceDir)
|
|
err := filepath.Walk(serviceDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() && strings.HasSuffix(path, defaultConfigFileExt) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
name := strings.TrimSuffix(info.Name(), defaultConfigFileExt)
|
|
l.Infof("Loading service: %v, from: %q", name, util.RelativePath(path, basePath))
|
|
svc, err := loadService(file, name, tag, basePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sq.Services = append(sq.Services, svc)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sq.Namespaces, err = loadNamespaces(l, sq.Service, basePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &sq, nil
|
|
}
|
|
|
|
func (sq Squadron) Service(name string) (Service, error) {
|
|
var available []string
|
|
for _, s := range sq.Services {
|
|
if s.Name == name {
|
|
return s, nil
|
|
}
|
|
available = append(available, s.Name)
|
|
}
|
|
return Service{}, errResourceNotFound(name, "service", available)
|
|
}
|
|
|
|
func (sq Squadron) Namespace(name string) (Namespace, error) {
|
|
var available []string
|
|
for _, ns := range sq.Namespaces {
|
|
if ns.name == name {
|
|
return ns, nil
|
|
}
|
|
available = append(available, ns.name)
|
|
}
|
|
return Namespace{}, errResourceNotFound(name, "namespace", available)
|
|
}
|
|
|
|
func (ns Namespace) ValidateGroup(name string) error {
|
|
var available []string
|
|
for _, g := range ns.groups {
|
|
if g == name {
|
|
return nil
|
|
}
|
|
available = append(available, g)
|
|
}
|
|
return errResourceNotFound(name, "squadron", available)
|
|
}
|
|
|
|
func (sq Squadron) Group(namespace, group string, tv TemplateVars) (Group, error) {
|
|
var wrapper struct {
|
|
Group Group `yaml:"squadron"`
|
|
}
|
|
ns, err := sq.Namespace(namespace)
|
|
if err != nil {
|
|
return wrapper.Group, err
|
|
}
|
|
if err := ns.ValidateGroup(group); err != nil {
|
|
return wrapper.Group, err
|
|
}
|
|
|
|
path := path.Join(sq.basePath, defaultNamespaceDir, namespace, group+defaultConfigFileExt)
|
|
bs, err := util.ExecuteTemplate(path, tv)
|
|
if err != nil {
|
|
return wrapper.Group, err
|
|
}
|
|
wrapper.Group.Name = group
|
|
if err := yaml.Unmarshal(bs, &wrapper); err != nil {
|
|
return wrapper.Group, err
|
|
}
|
|
return wrapper.Group, nil
|
|
}
|
|
|
|
func (sq Squadron) Build(s Service) (string, error) {
|
|
sq.l.Infof("Building service: %v", s.Name)
|
|
if s.Build.Command == "" {
|
|
return "", ErrBuildNotConfigured
|
|
}
|
|
args := []string{}
|
|
shell := "/bin/bash"
|
|
for _, envVarName := range []string{"SHELL", "SQUADRON_SHELL"} {
|
|
if os.Getenv(envVarName) != "" {
|
|
shell = os.Getenv(envVarName)
|
|
}
|
|
}
|
|
switch filepath.Base(shell) {
|
|
case "bash":
|
|
args = []string{"-c"}
|
|
case "zsh":
|
|
args = []string{"-c"}
|
|
}
|
|
args = append(args, s.Build.Command)
|
|
return util.NewCommand(sq.l, shell).Args(args...).Cwd(sq.basePath).Run()
|
|
|
|
}
|
|
|
|
func (sq Squadron) Push(s Service) (string, error) {
|
|
image := fmt.Sprintf("%v:%v", s.Build.Image, s.Build.Tag)
|
|
if s.Build.Image == "" || s.Build.Tag == "" {
|
|
return "", fmt.Errorf("invalid image %q to build service %q", image, s.Name)
|
|
}
|
|
sq.l.Infof("Pushing service %v to %s", s.Name, image)
|
|
return sq.dockerCmd.Push(s.Build.Image, s.Build.Tag)
|
|
}
|
|
|
|
func Init(l *logrus.Entry, dir string) (string, error) {
|
|
l.Infof("Copying example configuration into dir: %q", dir)
|
|
return "", exampledata.RestoreAssets(dir, "")
|
|
}
|
|
|
|
func (sq Squadron) CheckIngressController(name string) error {
|
|
pods, err := sq.kubeCmd.GetPodsByLabels([]string{fmt.Sprintf("app.kubernetes.io/name=%v", name)})
|
|
if err != nil {
|
|
return fmt.Errorf("error while checking for ingress controller %q: %s", name, err)
|
|
}
|
|
if len(pods) == 0 {
|
|
return fmt.Errorf("ingress controller %q not present on any namespace", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (sq Squadron) Install(group Group, tv TemplateVars, outputDir string) (string, error) {
|
|
groupChartPath := path.Join(sq.basePath, defaultOutputDir, outputDir, group.Name)
|
|
if err := sq.prepareChart(group, tv, groupChartPath, false); err != nil {
|
|
return "", err
|
|
}
|
|
return sq.helmCmd.Install(group.Name, groupChartPath)
|
|
}
|
|
|
|
func (sq Squadron) Generate(group Group, tv TemplateVars, outputDir string, useChartApiV1 bool) (string, error) {
|
|
groupChartPath := path.Join(sq.basePath, defaultOutputDir, outputDir, group.Name)
|
|
if err := sq.prepareChart(group, tv, groupChartPath, useChartApiV1); err != nil {
|
|
return "", err
|
|
}
|
|
return sq.helmCmd.Package(group.Name, groupChartPath, path.Join(sq.basePath, defaultOutputDir, outputDir))
|
|
}
|
|
|
|
func (sq Squadron) Uninstall(group string) (string, error) {
|
|
output, err := sq.helmCmd.Uninstall(group)
|
|
if err != nil {
|
|
return output, err
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
func (sq Squadron) Restart(services []string) (string, error) {
|
|
for _, service := range services {
|
|
sq.l.Infof("Waiting for service %q to get ready", service)
|
|
out, err := sq.kubeCmd.WaitForRollout(service, "30s").Run()
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
sq.l.Infof("Restarting service %q", service)
|
|
out, err = sq.kubeCmd.RestartDeployment(service).Run()
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func (sq Squadron) prepareChart(group Group, tv TemplateVars, groupChartPath string, useChartApiV1 bool) error {
|
|
sq.l.Infof("Preparing chart")
|
|
sq.l.Infof("Entering dir: %q", path.Join(sq.basePath, defaultOutputDir))
|
|
if _, err := os.Stat(groupChartPath); err == nil {
|
|
sq.l.Infof("Removing dir: %q", groupChartPath)
|
|
if err := os.RemoveAll(groupChartPath); err != nil {
|
|
sq.l.Warnf("could not delete group chart directory: %q", err)
|
|
}
|
|
}
|
|
|
|
sq.l.Printf("Creating dir: %q", groupChartPath)
|
|
if _, err := os.Stat(groupChartPath); os.IsNotExist(err) {
|
|
if err := os.MkdirAll(groupChartPath, 0744); err != nil {
|
|
return fmt.Errorf("could not create a workdir directory: %w", err)
|
|
}
|
|
}
|
|
|
|
groupChart := newChart(group.Name, group.Version)
|
|
for _, service := range group.Services() {
|
|
s, err := sq.Service(service)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
groupChart.Dependencies = append(groupChart.Dependencies, s.Chart)
|
|
}
|
|
if err := groupChart.generateChartFiles(groupChartPath, group.ServiceOverrides, useChartApiV1); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err := sq.helmCmd.UpdateDependency(group.Name, groupChartPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func loadService(reader io.Reader, name, tag, basePath string) (Service, error) {
|
|
var wrapper struct {
|
|
Service Service `yaml:"service"`
|
|
}
|
|
if err := yaml.NewDecoder(reader).Decode(&wrapper); err != nil {
|
|
return Service{}, fmt.Errorf("could not decode service: %w", err)
|
|
}
|
|
wrapper.Service.Name = name
|
|
if wrapper.Service.Build.Tag == "" {
|
|
wrapper.Service.Build.Tag = tag
|
|
}
|
|
if err := wrapper.Service.Chart.validate(basePath, name); err != nil {
|
|
return Service{}, err
|
|
}
|
|
return wrapper.Service, nil
|
|
}
|
|
|
|
func loadNamespaces(l *logrus.Entry, sl serviceLoader, basePath string) ([]Namespace, error) {
|
|
var nss []Namespace
|
|
namespaceDir := path.Join(basePath, defaultNamespaceDir)
|
|
err := filepath.Walk(namespaceDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() && path != namespaceDir {
|
|
l.Infof("Loading namespace: %v, from: %q", info.Name(), util.RelativePath(path, basePath))
|
|
gs, err := loadGroupNames(l, sl, basePath, info.Name())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ns := Namespace{
|
|
name: info.Name(),
|
|
groups: gs,
|
|
}
|
|
nss = append(nss, ns)
|
|
}
|
|
return nil
|
|
})
|
|
return nss, err
|
|
}
|
|
|
|
func loadGroupNames(l *logrus.Entry, sl serviceLoader, basePath, namespace string) ([]string, error) {
|
|
var gs []string
|
|
groupPath := path.Join(basePath, defaultNamespaceDir, namespace)
|
|
err := filepath.Walk(groupPath, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() && (strings.HasSuffix(path, defaultConfigFileExt)) {
|
|
name := strings.TrimSuffix(info.Name(), defaultConfigFileExt)
|
|
l.Infof("Loading squadron: %v, from: %q", name, util.RelativePath(path, basePath))
|
|
gs = append(gs, name)
|
|
}
|
|
return nil
|
|
})
|
|
return gs, err
|
|
}
|
|
|
|
func errResourceNotFound(name, resource string, available []string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("%s not provided. Available: %s", resource, strings.Join(available, ", "))
|
|
}
|
|
return fmt.Errorf("%s '%s' not found. Available: %s", resource, name, strings.Join(available, ", "))
|
|
}
|