Add custom resource health through lua

This commit is contained in:
Danny Thomson
2019-01-30 11:03:41 -08:00
committed by dthomson25
parent cefa9d9ba4
commit 00421bb46e
18 changed files with 833 additions and 11 deletions

28
Gopkg.lock generated
View File

@@ -778,6 +778,19 @@
pruneopts = ""
revision = "ecda9a501e8220fae3b4b600c3db4b0ba22cfc68"
[[projects]]
branch = "master"
digest = "1:525776d99293affd2c61dfb573007ff9f22863068c20c220ef3f58620758c341"
name = "github.com/yuin/gopher-lua"
packages = [
".",
"ast",
"parse",
"pm",
]
pruneopts = ""
revision = "732aa6820ec4fb93d60c4057dd574c33db8ad4e7"
[[projects]]
branch = "master"
digest = "1:2ea6df0f542cc95a5e374e9cdd81eaa599ed0d55366eef92d2f6b9efa2795c07"
@@ -1084,11 +1097,12 @@
version = "v0.1.2"
[[projects]]
digest = "1:81314a486195626940617e43740b4fa073f265b0715c9f54ce2027fee1cb5f61"
digest = "1:cedccf16b71e86db87a24f8d4c70b0a855872eb967cb906a66b95de56aefbd0d"
name = "gopkg.in/yaml.v2"
packages = ["."]
pruneopts = ""
revision = "eb3733d160e74a9c7e442f435eb3bea458e1d19f"
revision = "51d6538a90f86fe93ac480b35f37b2be17fef232"
version = "v2.2.2"
[[projects]]
branch = "release-1.12"
@@ -1387,6 +1401,14 @@
revision = "17c77c7898218073f14c8d573582e8d2313dc740"
version = "v1.12.2"
[[projects]]
branch = "master"
digest = "1:9b9f12f4c13ca4a4f4b4554c00ba46cb2910ff4079825d96d520b03c447e6da5"
name = "layeh.com/gopher-json"
packages = ["."]
pruneopts = ""
revision = "97fed8db84274c421dbfffbb28ec859901556b97"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
@@ -1439,6 +1461,7 @@
"github.com/vmihailenco/msgpack",
"github.com/yudai/gojsondiff",
"github.com/yudai/gojsondiff/formatter",
"github.com/yuin/gopher-lua",
"golang.org/x/crypto/bcrypt",
"golang.org/x/crypto/ssh",
"golang.org/x/crypto/ssh/terminal",
@@ -1509,6 +1532,7 @@
"k8s.io/kubernetes/pkg/apis/core",
"k8s.io/kubernetes/pkg/kubectl/scheme",
"k8s.io/kubernetes/pkg/util/node",
"layeh.com/gopher-json",
]
solver-name = "gps-cdcl"
solver-version = 1

View File

@@ -1005,8 +1005,8 @@ func (ctrl *ApplicationController) watchSettings(ctx context.Context) {
select {
case newSettings := <-updateCh:
newAppLabelKey := newSettings.GetAppInstanceLabelKey()
*ctrl.settings = *newSettings
if prevAppLabelKey != newAppLabelKey {
ctrl.settings.AppInstanceLabelKey = newAppLabelKey
log.Infof("label key changed: %s -> %s", prevAppLabelKey, newAppLabelKey)
ctrl.stateCache.Invalidate()
prevAppLabelKey = newAppLabelKey

View File

@@ -270,7 +270,7 @@ func (m *appStateManager) CompareAppState(app *v1alpha1.Application, revision st
syncStatus.Revision = manifestInfo.Revision
}
healthStatus, err := health.SetApplicationHealth(resourceSummaries, GetLiveObjs(managedResources))
healthStatus, err := health.SetApplicationHealth(resourceSummaries, GetLiveObjs(managedResources), m.settings.ResourceOverrides)
if err != nil {
conditions = append(conditions, appv1.ApplicationCondition{Type: v1alpha1.ApplicationConditionComparisonError, Message: err.Error()})
}

View File

@@ -0,0 +1,51 @@
hs = {}
if obj.status ~= nil then
if obj.status.conditions ~= nil then
for i, condition in ipairs(obj.status.conditions) do
if condition.type == "InvalidSpec" then
hs.status = "Degraded"
hs.message = condition.message
return hs
end
end
end
if obj.status.currentPodHash ~= nil then
if obj.spec.replicas ~= nil and obj.status.updatedReplicas < obj.spec.replicas then
hs.status = "Progressing"
hs.message = "Waiting for roll out to finish: More replicas need to be updated"
return hs
end
local verifyingPreview = false
if obj.status.verifyingPreview ~= nil then
verifyingPreview = obj.status.verifyingPreview
end
if verifyingPreview and obj.status.previewSelector ~= nil and obj.status.previewSelector == obj.status.currentPodHash then
hs.status = "Healthy"
hs.message = "The preview Service is serving traffic to the current pod spec"
return hs
end
if obj.status.replicas > obj.status.updatedReplicas then
hs.status = "Progressing"
hs.message = "Waiting for roll out to finish: old replicas are pending termination"
return hs
end
if obj.status.availableReplicas < obj.status.updatedReplicas then
hs.status = "Progressing"
hs.message = "Waiting for roll out to finish: updated replicas are still becoming available"
return hs
end
if obj.status.activeSelector ~= nil and obj.status.activeSelector == obj.status.currentPodHash then
hs.status = "Healthy"
hs.message = "The active Service is serving traffic to the current pod spec"
return hs
end
hs.status = "Progressing"
hs.message = "The current pod spec is not receiving traffic from the active service"
return hs
end
end
hs.status = "Unknown"
hs.message = "Rollout should not reach here. Please file a bug at https://github.com/argoproj/argo-cd/issues/new"
return hs

View File

@@ -0,0 +1,25 @@
tests:
- healthStatus:
status: Healthy
message: The active Service is serving traffic to the current pod spec
inputPath: testdata/healthy_servingActiveService.yaml
- healthStatus:
status: Healthy
message: The preview Service is serving traffic to the current pod spec
inputPath: testdata/healthy_servingPreviewService.yaml
- healthStatus:
status: Progressing
message: "Waiting for roll out to finish: More replicas need to be updated"
inputPath: testdata/progressing_addingMoreReplicas.yaml
- healthStatus:
status: Progressing
message: "Waiting for roll out to finish: old replicas are pending termination"
inputPath: testdata/progressing_killingOldReplicas.yaml
- healthStatus:
status: Progressing
message: "Waiting for roll out to finish: updated replicas are still becoming available"
inputPath: testdata/progressing_waitingUntilAvailable.yaml
- healthStatus:
status: Degraded
message: Rollout has missing field '.Spec.Strategy.Type'
inputPath: testdata/degraded_invalidSpec.yaml

View File

@@ -0,0 +1,12 @@
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: invalidSpec
status:
conditions:
- type: AnotherValidCondition
status: true
- type: InvalidSpec
status: true
message: Rollout has missing field '.Spec.Strategy.Type'
reason: MissingStrategy

View File

@@ -0,0 +1,57 @@
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"argoproj.io/v1alpha1","kind":"Rollout","metadata":{"annotations":{},"labels":{"app.kubernetes.io/instance":"guestbook-default","ksonnet.io/component":"guestbook-ui"},"name":"ks-guestbook-ui","namespace":"default"},"spec":{"minReadySeconds":30,"replicas":1,"selector":{"matchLabels":{"app":"ks-guestbook-ui"}},"strategy":{"blueGreen":{"activeService":"ks-guestbook-ui-active","previewService":"ks-guestbook-ui-preview"},"type":"BlueGreenUpdate"},"template":{"metadata":{"labels":{"app":"ks-guestbook-ui"}},"spec":{"containers":[{"image":"gcr.io/heptio-images/ks-guestbook-demo:0.2","name":"ks-guestbook-ui","ports":[{"containerPort":80}]}]}}}}
rollout.argoproj.io/revision: "1"
clusterName: ""
creationTimestamp: 2019-01-22T16:52:54Z
generation: 1
labels:
app.kubernetes.io/instance: guestbook-default
ksonnet.io/component: guestbook-ui
name: ks-guestbook-ui
namespace: default
resourceVersion: "153353"
selfLink: /apis/argoproj.io/v1alpha1/namespaces/default/rollouts/ks-guestbook-ui
uid: 29802403-1e66-11e9-a6a4-025000000001
spec:
minReadySeconds: 30
replicas: 1
selector:
matchLabels:
app: ks-guestbook-ui
strategy:
blueGreen:
activeService: ks-guestbook-ui-active
previewService: ks-guestbook-ui-preview
type: BlueGreenUpdate
template:
metadata:
creationTimestamp: null
labels:
app: ks-guestbook-ui
spec:
containers:
- image: gcr.io/heptio-images/ks-guestbook-demo:0.2
name: ks-guestbook-ui
ports:
- containerPort: 80
resources: {}
status:
activeSelector: dc689d967
availableReplicas: 1
conditions:
- lastTransitionTime: 2019-01-24T09:51:02Z
lastUpdateTime: 2019-01-24T09:51:02Z
message: Rollout is serving traffic from the active service.
reason: Available
status: "True"
type: Available
currentPodHash: dc689d967
observedGeneration: 77646c9d4c
previewSelector: ""
readyReplicas: 1
replicas: 1
updatedReplicas: 1

View File

@@ -0,0 +1,55 @@
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
annotations:
rollout.argoproj.io/revision: "7"
clusterName: ""
creationTimestamp: 2019-01-22T16:52:54Z
generation: 1
labels:
app.kubernetes.io/instance: guestbook-default
name: ks-guestbook-ui
namespace: default
resourceVersion: "164113"
selfLink: /apis/argoproj.io/v1alpha1/namespaces/default/rollouts/ks-guestbook-ui
uid: 29802403-1e66-11e9-a6a4-025000000001
spec:
minReadySeconds: 30
replicas: 3
selector:
matchLabels:
app: ks-guestbook-ui
strategy:
blueGreen:
activeService: ks-guestbook-ui-active
previewService: ks-guestbook-ui-preview
type: BlueGreenUpdate
template:
metadata:
creationTimestamp: null
labels:
app: ks-guestbook-ui
spec:
containers:
- image: gcr.io/heptio-images/ks-guestbook-demo:0.1
name: ks-guestbook-ui
ports:
- containerPort: 83
resources: {}
status:
activeSelector: 85f9884f5d
availableReplicas: 6
conditions:
- lastTransitionTime: 2019-01-25T07:44:26Z
lastUpdateTime: 2019-01-25T07:44:26Z
message: Rollout is serving traffic from the active service.
reason: Available
status: "True"
type: Available
currentPodHash: 697fb9575c
observedGeneration: 767f98959f
previewSelector: 697fb9575c
readyReplicas: 6
replicas: 6
updatedReplicas: 3
verifyingPreview: true

View File

@@ -0,0 +1,54 @@
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
annotations:
rollout.argoproj.io/revision: "7"
clusterName: ""
creationTimestamp: 2019-01-22T16:52:54Z
generation: 1
labels:
app.kubernetes.io/instance: guestbook-default
name: ks-guestbook-ui
namespace: default
resourceVersion: "164023"
selfLink: /apis/argoproj.io/v1alpha1/namespaces/default/rollouts/ks-guestbook-ui
uid: 29802403-1e66-11e9-a6a4-025000000001
spec:
minReadySeconds: 30
replicas: 3
selector:
matchLabels:
app: ks-guestbook-ui
strategy:
blueGreen:
activeService: ks-guestbook-ui-active
previewService: ks-guestbook-ui-preview
type: BlueGreenUpdate
template:
metadata:
creationTimestamp: null
labels:
app: ks-guestbook-ui
spec:
containers:
- image: gcr.io/heptio-images/ks-guestbook-demo:0.1
name: ks-guestbook-ui
ports:
- containerPort: 83
resources: {}
status:
activeSelector: 85f9884f5d
availableReplicas: 3
conditions:
- lastTransitionTime: 2019-01-25T07:44:26Z
lastUpdateTime: 2019-01-25T07:44:26Z
message: Rollout is serving traffic from the active service.
reason: Available
status: "True"
type: Available
currentPodHash: 697fb9575c
observedGeneration: 767f98959f
previewSelector: ""
readyReplicas: 3
replicas: 3
updatedReplicas: 0

View File

@@ -0,0 +1,57 @@
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"argoproj.io/v1alpha1","kind":"Rollout","metadata":{"annotations":{},"labels":{"app.kubernetes.io/instance":"guestbook-default","ksonnet.io/component":"guestbook-ui"},"name":"ks-guestbook-ui","namespace":"default"},"spec":{"minReadySeconds":30,"replicas":3,"selector":{"matchLabels":{"app":"ks-guestbook-ui"}},"strategy":{"blueGreen":{"activeService":"ks-guestbook-ui-active","previewService":"ks-guestbook-ui-preview"},"type":"BlueGreenUpdate"},"template":{"metadata":{"labels":{"app":"ks-guestbook-ui"}},"spec":{"containers":[{"image":"gcr.io/heptio-images/ks-guestbook-demo:0.1","name":"ks-guestbook-ui","ports":[{"containerPort":83}]}]}}}}
rollout.argoproj.io/revision: "7"
clusterName: ""
creationTimestamp: 2019-01-22T16:52:54Z
generation: 1
labels:
app.kubernetes.io/instance: guestbook-default
ksonnet.io/component: guestbook-ui
name: ks-guestbook-ui
namespace: default
resourceVersion: "164141"
selfLink: /apis/argoproj.io/v1alpha1/namespaces/default/rollouts/ks-guestbook-ui
uid: 29802403-1e66-11e9-a6a4-025000000001
spec:
minReadySeconds: 30
replicas: 3
selector:
matchLabels:
app: ks-guestbook-ui
strategy:
blueGreen:
activeService: ks-guestbook-ui-active
previewService: ks-guestbook-ui-preview
type: BlueGreenUpdate
template:
metadata:
creationTimestamp: null
labels:
app: ks-guestbook-ui
spec:
containers:
- image: gcr.io/heptio-images/ks-guestbook-demo:0.1
name: ks-guestbook-ui
ports:
- containerPort: 83
resources: {}
status:
activeSelector: 697fb9575c
availableReplicas: 6
conditions:
- lastTransitionTime: 2019-01-25T07:44:26Z
lastUpdateTime: 2019-01-25T07:44:26Z
message: Rollout is serving traffic from the active service.
reason: Available
status: "True"
type: Available
currentPodHash: 697fb9575c
observedGeneration: 767f98959f
previewSelector: ""
readyReplicas: 6
replicas: 6
updatedReplicas: 3

View File

@@ -0,0 +1,57 @@
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"argoproj.io/v1alpha1","kind":"Rollout","metadata":{"annotations":{},"labels":{"app.kubernetes.io/instance":"guestbook-default","ksonnet.io/component":"guestbook-ui"},"name":"ks-guestbook-ui","namespace":"default"},"spec":{"minReadySeconds":30,"replicas":3,"selector":{"matchLabels":{"app":"ks-guestbook-ui"}},"strategy":{"blueGreen":{"activeService":"ks-guestbook-ui-active","previewService":"ks-guestbook-ui-preview"},"type":"BlueGreenUpdate"},"template":{"metadata":{"labels":{"app":"ks-guestbook-ui"}},"spec":{"containers":[{"image":"gcr.io/heptio-images/ks-guestbook-demo:0.1","name":"ks-guestbook-ui","ports":[{"containerPort":83}]}]}}}}
rollout.argoproj.io/revision: "1"
clusterName: ""
creationTimestamp: 2019-01-25T16:19:09Z
generation: 1
labels:
app.kubernetes.io/instance: guestbook-default
ksonnet.io/component: guestbook-ui
name: ks-guestbook-ui
namespace: default
resourceVersion: "164590"
selfLink: /apis/argoproj.io/v1alpha1/namespaces/default/rollouts/ks-guestbook-ui
uid: f1b99cb0-20bc-11e9-a811-025000000001
spec:
minReadySeconds: 30
replicas: 3
selector:
matchLabels:
app: ks-guestbook-ui
strategy:
blueGreen:
activeService: ks-guestbook-ui-active
previewService: ks-guestbook-ui-preview
type: BlueGreenUpdate
template:
metadata:
creationTimestamp: null
labels:
app: ks-guestbook-ui
spec:
containers:
- image: gcr.io/heptio-images/ks-guestbook-demo:0.1
name: ks-guestbook-ui
ports:
- containerPort: 83
resources: {}
status:
activeSelector: 697fb9575c
availableReplicas: 0
conditions:
- lastTransitionTime: 2019-01-25T16:19:09Z
lastUpdateTime: 2019-01-25T16:19:09Z
message: Rollout is not serving traffic from the active service.
reason: Available
status: "False"
type: Available
currentPodHash: 697fb9575c
observedGeneration: 767f98959f
previewSelector: ""
readyReplicas: 3
replicas: 3
updatedReplicas: 3

View File

@@ -0,0 +1,66 @@
package resource_customizations
import (
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
"github.com/ghodss/yaml"
"github.com/stretchr/testify/assert"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/argoproj/argo-cd/errors"
appv1 "github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1"
"github.com/argoproj/argo-cd/util/lua"
)
type TestStructure struct {
Tests []IndividualTest `yaml:"tests"`
}
type IndividualTest struct {
InputPath string `yaml:"inputPath"`
HealthStatus appv1.HealthStatus `yaml:"healthStatus"`
}
func getObj(path string) *unstructured.Unstructured {
yamlBytes, err := ioutil.ReadFile(path)
errors.CheckError(err)
obj := make(map[string]interface{})
err = yaml.Unmarshal(yamlBytes, &obj)
errors.CheckError(err)
return &unstructured.Unstructured{Object: obj}
}
func TestLuaHealthScript(t *testing.T) {
err := filepath.Walk(".", func(path string, f os.FileInfo, err error) error {
if !strings.Contains(path, "health.lua") {
return nil
}
errors.CheckError(err)
dir := filepath.Dir(path)
yamlBytes, err := ioutil.ReadFile(dir + "/health_test.yaml")
errors.CheckError(err)
var resourceTest TestStructure
err = yaml.Unmarshal(yamlBytes, &resourceTest)
errors.CheckError(err)
for i := range resourceTest.Tests {
test := resourceTest.Tests[i]
t.Run(test.InputPath, func(t *testing.T) {
vm := lua.VM{
UseOpenLibs: true,
}
obj := getObj(filepath.Join(dir, test.InputPath))
script, err := vm.GetHealthScript(obj)
errors.CheckError(err)
result, err := vm.ExecuteHealthLua(obj, script)
errors.CheckError(err)
assert.Equal(t, &test.HealthStatus, result)
})
}
return nil
})
assert.Nil(t, err)
}

View File

@@ -3,8 +3,8 @@ package health
import (
"fmt"
"k8s.io/api/apps/v1"
appsv1 "k8s.io/api/apps/v1"
v1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
coreV1 "k8s.io/api/core/v1"
extv1beta1 "k8s.io/api/extensions/v1beta1"
@@ -16,10 +16,12 @@ import (
appv1 "github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1"
hookutil "github.com/argoproj/argo-cd/util/hook"
"github.com/argoproj/argo-cd/util/kube"
"github.com/argoproj/argo-cd/util/lua"
"github.com/argoproj/argo-cd/util/settings"
)
// SetApplicationHealth updates the health statuses of all resources performed in the comparison
func SetApplicationHealth(resStatuses []appv1.ResourceStatus, liveObjs []*unstructured.Unstructured) (*appv1.HealthStatus, error) {
func SetApplicationHealth(resStatuses []appv1.ResourceStatus, liveObjs []*unstructured.Unstructured, resourceOverrides map[string]settings.ResourceOverride) (*appv1.HealthStatus, error) {
var savedErr error
appHealth := appv1.HealthStatus{Status: appv1.HealthStatusHealthy}
for i, liveObj := range liveObjs {
@@ -28,7 +30,7 @@ func SetApplicationHealth(resStatuses []appv1.ResourceStatus, liveObjs []*unstru
if liveObj == nil {
resHealth = &appv1.HealthStatus{Status: appv1.HealthStatusMissing}
} else {
resHealth, err = GetResourceHealth(liveObj)
resHealth, err = GetResourceHealth(liveObj, resourceOverrides)
if err != nil && savedErr == nil {
savedErr = err
}
@@ -44,10 +46,22 @@ func SetApplicationHealth(resStatuses []appv1.ResourceStatus, liveObjs []*unstru
}
// GetResourceHealth returns the health of a k8s resource
func GetResourceHealth(obj *unstructured.Unstructured) (*appv1.HealthStatus, error) {
func GetResourceHealth(obj *unstructured.Unstructured, resourceOverrides map[string]settings.ResourceOverride) (*appv1.HealthStatus, error) {
var err error
var health *appv1.HealthStatus
health, err = getResourceHealthFromLuaScript(obj, resourceOverrides)
if err != nil {
health = &appv1.HealthStatus{
Status: appv1.HealthStatusUnknown,
Message: err.Error(),
}
return health, err
}
if health != nil {
return health, nil
}
gvk := obj.GroupVersionKind()
switch gvk.Group {
case "apps", "extensions":
@@ -113,6 +127,24 @@ func IsWorse(current, new appv1.HealthStatusCode) bool {
return newIndex > currentIndex
}
func getResourceHealthFromLuaScript(obj *unstructured.Unstructured, resourceOverrides map[string]settings.ResourceOverride) (*appv1.HealthStatus, error) {
luaVM := lua.VM{
ResourceOverrides: resourceOverrides,
}
script, err := luaVM.GetHealthScript(obj)
if err != nil {
return nil, err
}
if script == "" {
return nil, nil
}
result, err := luaVM.ExecuteHealthLua(obj, script)
if err != nil {
return nil, err
}
return result, nil
}
func getPVCHealth(obj *unstructured.Unstructured) (*appv1.HealthStatus, error) {
pvc := &coreV1.PersistentVolumeClaim{}
err := scheme.Scheme.Convert(obj, pvc, nil)

View File

@@ -18,7 +18,7 @@ func assertAppHealth(t *testing.T, yamlPath string, expectedStatus appv1.HealthS
var obj unstructured.Unstructured
err = yaml.Unmarshal(yamlBytes, &obj)
assert.Nil(t, err)
health, err := GetResourceHealth(&obj)
health, err := GetResourceHealth(&obj, nil)
assert.Nil(t, err)
assert.NotNil(t, health)
assert.Equal(t, expectedStatus, health.Status)
@@ -106,13 +106,13 @@ func TestSetApplicationHealth(t *testing.T) {
&runningPod,
&failedJob,
}
healthStatus, err := SetApplicationHealth(resources, liveObjs)
healthStatus, err := SetApplicationHealth(resources, liveObjs, nil)
assert.NoError(t, err)
assert.Equal(t, appv1.HealthStatusDegraded, healthStatus.Status)
// now mark the job as a hook and retry. it should ignore the hook and consider the app healthy
failedJob.SetAnnotations(map[string]string{common.AnnotationKeyHook: "PreSync"})
healthStatus, err = SetApplicationHealth(resources, liveObjs)
healthStatus, err = SetApplicationHealth(resources, liveObjs, nil)
assert.NoError(t, err)
assert.Equal(t, appv1.HealthStatusHealthy, healthStatus.Status)

178
util/lua/lua.go Normal file
View File

@@ -0,0 +1,178 @@
package lua
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/gobuffalo/packr"
log "github.com/sirupsen/logrus"
"github.com/yuin/gopher-lua"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
luajson "layeh.com/gopher-json"
appv1 "github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1"
"github.com/argoproj/argo-cd/util/settings"
)
const (
incorrectReturnType = "expect table output from Lua script, not %s"
invalidHealthStatus = "Lua returned an invalid health status"
resourceCustomizationBuiltInPath = "../../resource_customizations"
healthScript = "health.lua"
)
var (
box packr.Box
)
func init() {
box = packr.NewBox(resourceCustomizationBuiltInPath)
}
// VM Defines a struct that implements the luaVM
type VM struct {
ResourceOverrides map[string]settings.ResourceOverride
// UseOpenLibs flag to enable open libraries. Libraries are always disabled while running, but enabled during testing to allow the use of print statements
UseOpenLibs bool
}
func (vm VM) runLua(obj *unstructured.Unstructured, script string) (*lua.LState, error) {
l := lua.NewState(lua.Options{
SkipOpenLibs: !vm.UseOpenLibs,
})
defer l.Close()
// Opens table library to allow access to functions to manulate tables
for _, pair := range []struct {
n string
f lua.LGFunction
}{
{lua.LoadLibName, lua.OpenPackage},
{lua.BaseLibName, lua.OpenBase},
{lua.TabLibName, lua.OpenTable},
} {
if err := l.CallByParam(lua.P{
Fn: l.NewFunction(pair.f),
NRet: 0,
Protect: true,
}, lua.LString(pair.n)); err != nil {
panic(err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
l.SetContext(ctx)
objectValue := decodeValue(l, obj.Object)
l.SetGlobal("obj", objectValue)
err := l.DoString(script)
return l, err
}
// ExecuteHealthLua runs the lua script to generate the health status of a resource
func (vm VM) ExecuteHealthLua(obj *unstructured.Unstructured, script string) (*appv1.HealthStatus, error) {
l, err := vm.runLua(obj, script)
if err != nil {
return nil, err
}
returnValue := l.Get(-1)
if returnValue.Type() == lua.LTTable {
jsonBytes, err := luajson.Encode(returnValue)
if err != nil {
return nil, err
}
healthStatus := &appv1.HealthStatus{}
err = json.Unmarshal(jsonBytes, healthStatus)
if err != nil {
return nil, err
}
if !isValidHealthStatusCode(healthStatus.Status) {
return &appv1.HealthStatus{
Status: appv1.HealthStatusUnknown,
Message: invalidHealthStatus,
}, nil
}
return healthStatus, nil
}
return nil, fmt.Errorf(incorrectReturnType, returnValue.Type().String())
}
// GetScript attempts to read lua script from config and then filesystem for that resource
func (vm VM) GetHealthScript(obj *unstructured.Unstructured) (string, error) {
key := getConfigMapKey(obj)
if script, ok := vm.ResourceOverrides[key]; ok && script.HealthLua != "" {
return script.HealthLua, nil
}
return vm.getPredefinedLuaScripts(key, healthScript)
}
func getConfigMapKey(obj *unstructured.Unstructured) string {
gvk := obj.GroupVersionKind()
if gvk.Group == "" {
return gvk.Kind
}
return fmt.Sprintf("%s/%s", gvk.Group, gvk.Kind)
}
func (vm VM) getPredefinedLuaScripts(objKey string, scriptType string) (string, error) {
data, err := box.MustBytes(filepath.Join(objKey, scriptType))
if err != nil {
if os.IsNotExist(err) {
log.Debugf("No Lua Script found for resource key '%s'", objKey)
return "", nil
}
return "", err
}
return string(data), nil
}
func isValidHealthStatusCode(statusCode string) bool {
switch statusCode {
case appv1.HealthStatusUnknown, appv1.HealthStatusProgressing, appv1.HealthStatusHealthy, appv1.HealthStatusDegraded, appv1.HealthStatusMissing:
return true
}
return false
}
// Took logic from the link below and added the int, int32, and int64 types since the value would have type int64
// while actually running in the controller and it was not reproducible through testing.
// https://github.com/layeh/gopher-json/blob/97fed8db84274c421dbfffbb28ec859901556b97/json.go#L154
func decodeValue(L *lua.LState, value interface{}) lua.LValue {
switch converted := value.(type) {
case bool:
return lua.LBool(converted)
case float64:
return lua.LNumber(converted)
case string:
return lua.LString(converted)
case json.Number:
return lua.LString(converted)
case int:
return lua.LNumber(converted)
case int32:
return lua.LNumber(converted)
case int64:
return lua.LNumber(converted)
case []interface{}:
arr := L.CreateTable(len(converted), 0)
for _, item := range converted {
arr.Append(decodeValue(L, item))
}
return arr
case map[string]interface{}:
tbl := L.CreateTable(0, len(converted))
for key, item := range converted {
tbl.RawSetH(lua.LString(key), decodeValue(L, item))
}
return tbl
case nil:
return lua.LNil
}
return lua.LNil
}

125
util/lua/lua_test.go Normal file
View File

@@ -0,0 +1,125 @@
package lua
import (
"fmt"
"testing"
"github.com/ghodss/yaml"
"github.com/stretchr/testify/assert"
lua "github.com/yuin/gopher-lua"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
appv1 "github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1"
)
const objJSON = `
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
labels:
app.kubernetes.io/instance: helm-guestbook
name: helm-guestbook
namespace: default
resourceVersion: "123"
`
const objWithNoScriptJSON = `
apiVersion: not-an-endpoint.io/v1alpha1
kind: Test
metadata:
labels:
app.kubernetes.io/instance: helm-guestbook
name: helm-guestbook
namespace: default
resourceVersion: "123"
`
const newHealthStatusFunction = `a = {}
a.status = "Healthy"
a.message ="NeedsToBeChanged"
if obj.metadata.name == "helm-guestbook" then
a.message = "testMessage"
end
return a`
func StrToUnstructured(jsonStr string) *unstructured.Unstructured {
obj := make(map[string]interface{})
err := yaml.Unmarshal([]byte(jsonStr), &obj)
if err != nil {
panic(err)
}
return &unstructured.Unstructured{Object: obj}
}
func TestExecuteNewHealthStatusFunction(t *testing.T) {
testObj := StrToUnstructured(objJSON)
vm := VM{}
status, err := vm.ExecuteHealthLua(testObj, newHealthStatusFunction)
assert.Nil(t, err)
expectedHealthStatus := &appv1.HealthStatus{
Status: "Healthy",
Message: "testMessage",
}
assert.Equal(t, expectedHealthStatus, status)
}
const osLuaScript = `os.getenv("HOME")`
func TestFailExternalLibCall(t *testing.T) {
testObj := StrToUnstructured(objJSON)
vm := VM{}
_, err := vm.ExecuteHealthLua(testObj, osLuaScript)
assert.Error(t, err, "")
assert.IsType(t, &lua.ApiError{}, err)
}
const returnInt = `return 1`
func TestFailLuaReturnNonTable(t *testing.T) {
testObj := StrToUnstructured(objJSON)
vm := VM{}
_, err := vm.ExecuteHealthLua(testObj, returnInt)
assert.Equal(t, fmt.Errorf(incorrectReturnType, "number"), err)
}
const invalidHealthStatusStatus = `local healthStatus = {}
healthStatus.status = "test"
return healthStatus
`
func TestInvalidHealthStatusStatus(t *testing.T) {
testObj := StrToUnstructured(objJSON)
vm := VM{}
status, err := vm.ExecuteHealthLua(testObj, invalidHealthStatusStatus)
assert.Nil(t, err)
expectedStatus := &appv1.HealthStatus{
Status: appv1.HealthStatusUnknown,
Message: invalidHealthStatus,
}
assert.Equal(t, expectedStatus, status)
}
const infiniteLoop = `while true do ; end`
func TestHandleInfiniteLoop(t *testing.T) {
testObj := StrToUnstructured(objJSON)
vm := VM{}
_, err := vm.ExecuteHealthLua(testObj, infiniteLoop)
assert.IsType(t, &lua.ApiError{}, err)
}
func TestGetPredefinedLuaScript(t *testing.T) {
testObj := StrToUnstructured(objJSON)
vm := VM{}
script, err := vm.GetHealthScript(testObj)
assert.Nil(t, err)
assert.NotEmpty(t, script)
}
func TestGetNonExistentPredefinedLuaScript(t *testing.T) {
testObj := StrToUnstructured(objWithNoScriptJSON)
vm := VM{}
script, err := vm.GetHealthScript(testObj)
assert.Nil(t, err)
assert.Equal(t, "", script)
}

1
util/lua/testdata/example.lua vendored Normal file
View File

@@ -0,0 +1 @@
return 'Hello World'

View File

@@ -63,6 +63,13 @@ type ArgoCDSettings struct {
HelmRepositories []HelmRepoCredentials
// AppInstanceLabelKey is the configured application instance label key used to label apps. May be empty
AppInstanceLabelKey string
// ResourceOverrides holds the overrides for specific resources. The keys are in the format of `group/kind`
// (e.g. argoproj.io/rollout) for the resource that is being overridden
ResourceOverrides map[string]ResourceOverride
}
type ResourceOverride struct {
HealthLua string `json:"health.lua,omitempty"`
}
type OIDCConfig struct {
@@ -118,6 +125,8 @@ const (
settingsWebhookBitbucketUUIDKey = "webhook.bitbucket.uuid"
// settingsApplicationInstanceLabelKey is the key to configure injected app instance label key
settingsApplicationInstanceLabelKey = "application.instanceLabelKey"
// resourcesCustomizationsKey is the key to the map of resource overrides
resourcesCustomizationsKey = "resource.customizations"
)
// SettingsManager holds config info for a new manager with which to access Kubernetes ConfigMaps.
@@ -326,6 +335,16 @@ func updateSettingsFromConfigMap(settings *ArgoCDSettings, argoCDCM *apiv1.Confi
settings.HelmRepositories = helmRepositories
}
}
if value, ok := argoCDCM.Data[resourcesCustomizationsKey]; ok {
resourceOverrides := map[string]ResourceOverride{}
err := yaml.Unmarshal([]byte(value), &resourceOverrides)
if err != nil {
errors = append(errors, err)
} else {
settings.ResourceOverrides = resourceOverrides
}
}
if len(errors) > 0 {
return errors[0]
}
@@ -437,6 +456,15 @@ func (mgr *SettingsManager) SaveSettings(settings *ArgoCDSettings) error {
} else {
delete(argoCDCM.Data, settingsApplicationInstanceLabelKey)
}
if len(settings.ResourceOverrides) > 0 {
yamlBytes, err := yaml.Marshal(settings.ResourceOverrides)
if err != nil {
return err
}
argoCDCM.Data[resourcesCustomizationsKey] = string(yamlBytes)
}
if createCM {
_, err = mgr.clientset.CoreV1().ConfigMaps(mgr.namespace).Create(argoCDCM)
} else {