Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: local apply #142

Closed
wants to merge 11 commits into from
41 changes: 41 additions & 0 deletions cmd/apply.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package cmd

import (
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"

"github.com/mykso/myks/internal/myks"
)

func init() {
cmd := &cobra.Command{
Use: "apply",
Short: "Apply k8s manifests to current context",
Long: `TODO`,
Annotations: map[string]string{
ANNOTATION_SMART_MODE: ANNOTATION_TRUE,
},
Run: func(cmd *cobra.Command, args []string) {
log.Info().Msg("Applying k8s manifests")
g := myks.New(".")

if err := g.ValidateRootDir(); err != nil {
log.Fatal().Err(err).Msg("Root directory is not suitable for myks")
}

Check warning on line 24 in cmd/apply.go

View check run for this annotation

Codecov / codecov/patch

cmd/apply.go#L19-L24

Added lines #L19 - L24 were not covered by tests

if err := g.Init(asyncLevel, envAppMap); err != nil {
log.Fatal().Err(err).Msg("Unable to initialize myks's globe")
}

Check warning on line 28 in cmd/apply.go

View check run for this annotation

Codecov / codecov/patch

cmd/apply.go#L26-L28

Added lines #L26 - L28 were not covered by tests

if !g.SingleEnv() {
log.Fatal().Msg("Local apply can only be used with a specific environment. Make sure you are connected to the right cluster.")
}

Check warning on line 32 in cmd/apply.go

View check run for this annotation

Codecov / codecov/patch

cmd/apply.go#L30-L32

Added lines #L30 - L32 were not covered by tests

if err := g.Apply(asyncLevel); err != nil {
log.Fatal().Err(err).Msg("Unable to apply k8s manifests")
}

Check warning on line 36 in cmd/apply.go

View check run for this annotation

Codecov / codecov/patch

cmd/apply.go#L34-L36

Added lines #L34 - L36 were not covered by tests
},
}

rootCmd.AddCommand(cmd)
}
159 changes: 140 additions & 19 deletions internal/myks/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"

Expand All @@ -12,14 +13,17 @@
)

const (
renderStepName = "render"
syncStepName = "sync"
globalYttStepName = "global-ytt"
yttStepName = "ytt"
yttPkgStepName = "ytt-pkg"
helmStepName = "helm"
sliceStepName = "slice"
initStepName = "init"
renderStepName = "render"
syncStepName = "sync"
globalYttStepName = "global-ytt"
yttStepName = "ytt"
yttPkgStepName = "ytt-pkg"
helmStepName = "helm"
sliceStepName = "slice"
initStepName = "init"
applyStepName = "apply"
namespaceResourcePrefix = "namespace-"
crdPrefix = "customresourcedefinition- "
)

type Application struct {
Expand All @@ -28,11 +32,12 @@

e *Environment

argoCDEnabled bool
includeNamespace bool
useCache bool
yttDataFiles []string
yttPkgDirs []string
includeNamespace bool
ArgoConfig *ArgoConfig
HelmConfig *HelmConfig
}

type HelmConfig struct {
Expand All @@ -42,6 +47,19 @@
Capabilities []string `yaml:"capabilities"`
}

type Destination struct {
Namespace string `yaml:"namespace"`
}

type App struct {
Destination Destination `yaml:"destination"`
}

type ArgoConfig struct {
Enabled bool `yaml:"enabled"`
App App `yaml:"app"`
}

var (
ErrNoVendirConfig = errors.New("no vendir config found")
ApplicationLogFormat = "\033[1m[%s > %s > %s]\033[0m %s"
Expand Down Expand Up @@ -71,7 +89,7 @@
}

func (a *Application) Init() error {
// 1. Collect all ytt data files:
// Collect all ytt data files:
// - environment data files: `envs/**/env-data.ytt.yaml`
// - application prototype data file: `prototypes/<prototype>/app-data.ytt.yaml`
// - application data files: `envs/**/_apps/<app>/add-data.ytt.yaml`
Expand All @@ -83,32 +101,51 @@
return err
}

type ArgoCD struct {
Enabled bool `yaml:"enabled"`
}

// Read all sorts of application config
var applicationData struct {
YttPkg struct {
Dirs []string `yaml:"dirs"`
} `yaml:"yttPkg"`
ArgoCD ArgoCD `yaml:"argocd"`
Sync struct {
ArgoConfig ArgoConfig `yaml:"argocd"`
Sync struct {
UseCache bool `yaml:"useCache"`
} `yaml:"sync"`
Render struct {
IncludeNamespace bool `yaml:"includeNamespace"`
} `yaml:"render"`
}

err = yaml.Unmarshal(dataYaml, &applicationData)
if err != nil {
return err
}
a.argoCDEnabled = applicationData.ArgoCD.Enabled
a.useCache = applicationData.Sync.UseCache
a.includeNamespace = applicationData.Render.IncludeNamespace
a.yttPkgDirs = applicationData.YttPkg.Dirs

// Transfer ArgoConfig if enabled
var argoData struct {
ArgoConfig ArgoConfig `yaml:"argocd"`
}
err = yaml.Unmarshal(dataYaml, &argoData)
if err != nil {
log.Warn().Err(err).Msg(a.Msg(helmStepName, "Unable to unmarshal argo config"))
return err
}

Check warning on line 133 in internal/myks/application.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/application.go#L131-L133

Added lines #L131 - L133 were not covered by tests
if argoData.ArgoConfig.Enabled {
a.ArgoConfig = &argoData.ArgoConfig
}

// Transfer HelmConfig
var helmConfig struct {
Helm HelmConfig
}
err = yaml.Unmarshal(dataYaml, &helmConfig)
if err != nil {
log.Warn().Err(err).Msg(a.Msg(helmStepName, "Unable to unmarshal helm config"))
return err
}

Check warning on line 146 in internal/myks/application.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/application.go#L144-L146

Added lines #L144 - L146 were not covered by tests
a.HelmConfig = &helmConfig.Helm

return nil
}

Expand Down Expand Up @@ -207,3 +244,87 @@
func (a *Application) prototypeDirName() string {
return strings.TrimPrefix(a.Prototype, a.e.g.PrototypesDir+string(filepath.Separator))
}

func (a *Application) localApply() error {
var helmNamespace, argoNamespace, namespace string
helmNamespace = a.HelmConfig.Namespace
if a.ArgoConfig != nil {
argoNamespace = a.ArgoConfig.App.Destination.Namespace
}
namespace, err := decideNamespace(helmNamespace, argoNamespace, a.Name)
if err != nil {
return err
}

Check warning on line 257 in internal/myks/application.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/application.go#L248-L257

Added lines #L248 - L257 were not covered by tests
// check namespace exists, if not create it
_, err = a.runCmd(applyStepName, "check namespace exists", "kubectl", nil, []string{"get", "namespace", namespace})
if err != nil {
_, err = a.runCmd(applyStepName, "create namespace", "kubectl", nil, []string{"create", "namespace", namespace})
log.Info().Msg("Create namespace: " + namespace)
if err != nil {
return err
}

Check warning on line 265 in internal/myks/application.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/application.go#L259-L265

Added lines #L259 - L265 were not covered by tests
}
// set default namespace
configArgs := []string{"config", "set-context", "--current", "--namespace", namespace}
_, err = a.runCmd(applyStepName, "set default namespace", "kubectl", nil, configArgs)
if err != nil {
return err
}
Comment on lines +249 to +272
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following the "source of truth" and "explicit better than implicit" ideas, I'd rather force users to explicitly define namespaces in their resources.

We can provide an overlay with the logic similar to the logic currently implemented in this code. Something similar to this:

#! This file contains YTT overlay for setting namespace to resources without namespace.

#@ load("@ytt:data", "data")
#@ load("@ytt:overlay", "overlay")

#@ def select_for_namespace(i, l, r):
#@   if "metadata" in l:
#@     return "namespace" not in l["metadata"] or not l["metadata"]["namespace"]
#@   end
#@   return False
#@ end

#@ d = data.values
#@ namespace = d.argocd.app.destination.namespace or d.helm.namespace or d.myks.context.app

#@overlay/match by=select_for_namespace, when="1+"
---
#@overlay/match-child-defaults missing_ok=True
metadata:
  namespace: #@ namespace

We should consider providing an explicit way to set the namespace for the application. Before it was not needed, because we were setting the namespace via the ArgoCD part of the configuration. Now, when we make ArgoCD optional, we should have another way. In the past, I remember using just .application.namespace when needed. This can be sufficient, but I'm not entirely sure if we should invade this "user-defined" area of configuration with pre-defined options.

Another minor point is about the usage of kubectl. Instead of changing the current namespace, one can simply use --namespace flag of kubectl.

apply := func(path string) error {
applyArgs := []string{"apply", "--filename", path}
res, err := a.runCmd(applyStepName, "apply k8s manifest", "kubectl", nil, applyArgs)
log.Info().Msg(fmt.Sprintf("Apply result: %s", strings.ReplaceAll(res.Stdout, "\n", "")))
return err
}
applyDir := func(filter func(path string) bool) error {
err = filepath.WalkDir(a.getDestinationDir(), func(path string, info os.DirEntry, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if strings.HasSuffix(path, ".yaml") {
if filter(filepath.Base(path)) {
return apply(path)
}
return nil

Check warning on line 291 in internal/myks/application.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/application.go#L268-L291

Added lines #L268 - L291 were not covered by tests
}
return fmt.Errorf("file %s is not a yaml file", path)

Check warning on line 293 in internal/myks/application.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/application.go#L293

Added line #L293 was not covered by tests
})
return err

Check warning on line 295 in internal/myks/application.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/application.go#L295

Added line #L295 was not covered by tests
}

// 1. apply CRDs and namespaces
err = applyDir(func(path string) bool {
return strings.HasPrefix(path, crdPrefix) || strings.HasPrefix(path, namespaceResourcePrefix)
})

Check warning on line 301 in internal/myks/application.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/application.go#L299-L301

Added lines #L299 - L301 were not covered by tests

if err != nil {
return fmt.Errorf("unable to apply CRDs: %w", err)
}

Check warning on line 305 in internal/myks/application.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/application.go#L303-L305

Added lines #L303 - L305 were not covered by tests

// 2. apply for all other yamls
err = applyDir(func(path string) bool {
return !strings.HasPrefix(path, crdPrefix)
})

Check warning on line 310 in internal/myks/application.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/application.go#L308-L310

Added lines #L308 - L310 were not covered by tests

if err != nil {
return fmt.Errorf("unable to apply K8s manifests: %w", err)
}
return nil

Check warning on line 315 in internal/myks/application.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/application.go#L312-L315

Added lines #L312 - L315 were not covered by tests
}

func decideNamespace(helmNamespace string, argoNamespace string, defaultNamespace string) (string, error) {
if helmNamespace == "" && argoNamespace == "" {
return defaultNamespace, nil
} else if helmNamespace == "" {
return argoNamespace, nil
} else if argoNamespace == "" {
return helmNamespace, nil
} else if helmNamespace == argoNamespace {
return helmNamespace, nil
} else {
return "", fmt.Errorf("Helm namespace %s is different from ArgoCD namespace %s", helmNamespace, argoNamespace)
}
}
32 changes: 32 additions & 0 deletions internal/myks/application_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,35 @@ func TestApplication_prototypeDir(t *testing.T) {
})
}
}

func Test_decideNamespace(t *testing.T) {
type args struct {
helmNamespace string
argoNamespace string
namespace string
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{"happy path", args{"my-namespace", "my-namespace", "namespace"}, "my-namespace", false},
{"helm namespace", args{"my-namespace", "", "namespace"}, "my-namespace", false},
{"argo namespace", args{"", "my-namespace", "namespace"}, "my-namespace", false},
{"default namespace", args{"", "", "namespace"}, "namespace", false},
{"mismatch", args{"helm-namespace", "argo-namespace", "namespace"}, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := decideNamespace(tt.args.helmNamespace, tt.args.argoNamespace, tt.args.namespace)
if (err != nil) != tt.wantErr {
t.Errorf("decideNamespace() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("decideNamespace() got = %v, want %v", got, tt.want)
}
})
}
}
16 changes: 16 additions & 0 deletions internal/myks/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@
return e.Cleanup()
}

func (e *Environment) Apply(asyncLevel int) error {
err := process(asyncLevel, e.Applications, func(item interface{}) error {
app, ok := item.(*Application)
if !ok {
return fmt.Errorf("Unable to cast item to *Application")
}
return app.localApply()

Check warning on line 119 in internal/myks/environment.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/environment.go#L113-L119

Added lines #L113 - L119 were not covered by tests
})
if err != nil {
log.Error().Err(err).Msg(e.Msg("Unable to apply k8s manifests"))
return err
}

Check warning on line 124 in internal/myks/environment.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/environment.go#L121-L124

Added lines #L121 - L124 were not covered by tests

return e.Cleanup()

Check warning on line 126 in internal/myks/environment.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/environment.go#L126

Added line #L126 was not covered by tests
}

func (e *Environment) SyncAndRender(asyncLevel int, vendirSecrets string) error {
if err := e.renderArgoCD(); err != nil {
return err
Expand Down
20 changes: 19 additions & 1 deletion internal/myks/globe.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@

// Application data file name
ApplicationDataFileName string `default:"app-data.ytt.yaml"`
// ArgoCD data directory name
// ArgoConfig data directory name
ArgoCDDataDirName string `default:"argocd"`
// Data values schema file name
DataSchemaFileName string `default:"data-schema.ytt.yaml"`
Expand Down Expand Up @@ -196,6 +196,16 @@
})
}

func (g *Globe) Apply(asyncLevel int) error {
return process(asyncLevel, g.environments, func(item interface{}) error {
env, ok := item.(*Environment)
if !ok {
return fmt.Errorf("Unable to cast item to *Environment")
}
return env.Apply(asyncLevel)

Check warning on line 205 in internal/myks/globe.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/globe.go#L199-L205

Added lines #L199 - L205 were not covered by tests
})
}

func (g *Globe) Render(asyncLevel int) error {
return process(asyncLevel, g.environments, func(item interface{}) error {
env, ok := item.(*Environment)
Expand Down Expand Up @@ -390,3 +400,11 @@
formattedMessage := fmt.Sprintf(GlobalLogFormat, msg)
return formattedMessage
}

func (g *Globe) SingleEnv() bool {
if g.environments == nil {
log.Error().Msg("Invoking SingleEnv() before Init()")
return false
}

Check warning on line 408 in internal/myks/globe.go

View check run for this annotation

Codecov / codecov/patch

internal/myks/globe.go#L406-L408

Added lines #L406 - L408 were not covered by tests
return len(g.environments) == 1
}
28 changes: 28 additions & 0 deletions internal/myks/globe_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package myks

import "testing"

func TestGlobe_SingleEnv(t *testing.T) {
type fields struct {
environments map[string]*Environment
}
tests := []struct {
name string
fields fields
want bool
}{
{"before init", fields{map[string]*Environment{}}, false},
{"happy path", fields{map[string]*Environment{"test-env": {}}}, true},
{"sad path", fields{map[string]*Environment{"test-env": {}, "test-env2": {}}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := &Globe{
environments: tt.fields.environments,
}
if got := g.SingleEnv(); got != tt.want {
t.Errorf("SingleEnv() = %v, want %v", got, tt.want)
}
})
}
}
Loading
Loading