Skip to content
This repository has been archived by the owner on Mar 11, 2021. It is now read-only.

Commit

Permalink
Add deployments API to compute quota necessary for scale-up (#2286)
Browse files Browse the repository at this point in the history
This PR adds a new API serviced by the deployments controller at /deployments/spaces/{spaceID}/applications/{appName}/deployments/{deployName}/podlimits.

Calling this API on a particular deployment determines the CPU and memory resources required in order to add a new pod to the deployment. This will allow the front-end to decide whether to stop the user from attempting to scale up a deployment if they do not have sufficient quota to do so successfully.

Example usage:
Request: GET https://openshift.io/api/deployments/spaces/$SPACE/applications/$APP/deployments/run/podlimits
Response: {"data":{"limits":{"cpucores":1,"memory":262144000}}}

This work was initially done by @chrislessard, and I have added tests and some modifications.

Fixes: openshiftio/openshift.io#3388
  • Loading branch information
ebaron committed Sep 26, 2018
1 parent 2a95482 commit cdef2ae
Show file tree
Hide file tree
Showing 9 changed files with 2,406 additions and 0 deletions.
25 changes: 25 additions & 0 deletions controller/deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,31 @@ func (c *DeploymentsController) ShowDeploymentStatSeries(ctx *app.ShowDeployment
return ctx.OK(res)
}

// ShowDeploymentPodLimitRange runs the showDeploymentPodLimitRange action.
func (c *DeploymentsController) ShowDeploymentPodLimitRange(ctx *app.ShowDeploymentPodLimitRangeDeploymentsContext) error {
// Inputs : spaceId, appName, deployName
kc, err := c.GetKubeClient(ctx)
if err != nil {
return jsonapi.JSONErrorResponse(ctx, err)
}
spaceName, err := c.getSpaceNameFromSpaceID(ctx, ctx.SpaceID)
if err != nil {
return jsonapi.JSONErrorResponse(ctx, err)
}

quotas, err := kc.GetDeploymentPodQuota(*spaceName, ctx.AppName, ctx.DeployName)

if err != nil {
return jsonapi.JSONErrorResponse(ctx, err)
}

res := &app.SimpleDeploymentPodLimitRangeSingle{
Data: quotas,
}

return ctx.OK(res)
}

func convertToTime(unixMillis int64) time.Time {
return time.Unix(0, unixMillis*int64(time.Millisecond))
}
Expand Down
27 changes: 27 additions & 0 deletions design/deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ var simpleDeploymentStatSeries = a.Type("SimpleDeploymentStatSeries", func() {
a.Attribute("net_rx", a.ArrayOf(timedNumberTuple))
})

var simpleDeploymentPodLimitRange = a.Type("SimpleDeploymentPodLimitRange", func() {
a.Description("pod limit range")
a.Attribute("limits", podsQuota)
})

var simpleSpaceSingle = JSONSingle(
"SimpleSpace", "Holds a single response to a space request",
simpleSpace,
Expand All @@ -172,6 +177,11 @@ var simpleDeploymentStatSeriesSingle = JSONSingle(
simpleDeploymentStatSeries,
nil)

var simpleDeploymentPodLimitRangeSingle = JSONSingle(
"simpleDeploymentPodLimitRange", "Holds a response to a pod limit range query",
simpleDeploymentPodLimitRange,
nil)

var _ = a.Resource("deployments", func() {
a.BasePath("/deployments")

Expand Down Expand Up @@ -231,6 +241,23 @@ var _ = a.Resource("deployments", func() {
a.Response(d.BadRequest, JSONAPIErrors)
})

a.Action("showDeploymentPodLimitRange", func() {
a.Routing(
a.GET("/spaces/:spaceID/applications/:appName/deployments/:deployName/podlimits"),
)
a.Description("get pod resource limit range")
a.Params(func() {
a.Param("spaceID", d.UUID, "ID of the space")
a.Param("appName", d.String, "Name of the application")
a.Param("deployName", d.String, "Name of the deployment")
})
a.Response(d.OK, simpleDeploymentPodLimitRangeSingle)
a.Response(d.Unauthorized, JSONAPIErrors)
a.Response(d.InternalServerError, JSONAPIErrors)
a.Response(d.NotFound, JSONAPIErrors)
a.Response(d.BadRequest, JSONAPIErrors)
})

a.Action("setDeployment", func() {
a.Routing(
a.PUT("/spaces/:spaceID/applications/:appName/deployments/:deployName"),
Expand Down
169 changes: 169 additions & 0 deletions kubernetes/deployments_kubeclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ type KubeClientInterface interface {
GetEnvironment(envName string) (*app.SimpleEnvironment, error)
GetMetricsClient(envNS string) (Metrics, error)
WatchEventsInNamespace(nameSpace string) (*cache.FIFO, chan struct{})
GetDeploymentPodQuota(spaceName string, appName string, envName string) (*app.SimpleDeploymentPodLimitRange, error)
Close()
KubeAccessControl
}
Expand Down Expand Up @@ -163,6 +164,8 @@ var _ KubeClientInterface = (*kubeClient)(nil)
// Receiver for default implementation of KubeRESTAPIGetter and MetricsGetter
type defaultGetter struct{}

const limitRangeName = "resource-limits"

// NewKubeClient creates a KubeClientInterface given a configuration. The returned
// KubeClientInterface must be closed using the Close method, when no longer needed.
func NewKubeClient(config *KubeClientConfig) (KubeClientInterface, error) {
Expand Down Expand Up @@ -1482,6 +1485,172 @@ func (kc *kubeClient) getPodsQuota(pods []*v1.Pod) (*app.PodsQuota, error) {
return result, nil
}

func (kc *kubeClient) GetDeploymentPodQuota(spaceName string, appName string, envName string) (*app.SimpleDeploymentPodLimitRange, error) {
// Find namespace for environment name
namespace, err := kc.getDeployableEnvironmentNamespace(envName)
if err != nil {
return nil, err
}
dcName, err := kc.getDeploymentConfigNameForApp(namespace, appName, spaceName)
if err != nil {
return nil, errs.Errorf("could not retrieve deployment with the given namespace %s, app name %s and space name %s", namespace, appName, spaceName)
}

deploymentConfig, err := kc.GetDeploymentConfig(namespace, dcName)
if err != nil {
return nil, errs.Errorf("could not retrieve deployment config with name %s for namespace %s", dcName, namespace)
} else if deploymentConfig == nil {
return nil, errors.NewNotFoundErrorFromString(fmt.Sprintf("no deployment config found named %s in %s", dcName, namespace))
}

spec, ok := deploymentConfig["spec"].(map[string]interface{})
if !ok {
return nil, errs.Errorf("spec is missing from deployment config %s: %+v", dcName, spec)
}

template, ok := spec["template"].(map[string]interface{})
if !ok {
return nil, errs.Errorf("template is missing from deployment config %s: %+v", dcName, template)
}

innerSpec, ok := template["spec"].(map[string]interface{})
if !ok {
return nil, errs.Errorf("inner spec is missing from deployment config %s: %+v", dcName, innerSpec)
}

// This should be checked, to see if maps[string]interface is appropriate type for iterable arr
containers, ok := innerSpec["containers"].([]interface{})
if !ok {
return nil, errs.Errorf("containers is missing from deployment config %s: %+v", dcName, containers)
}

numContainersMissingCPU := float64(0)
numContainersMissingMem := float64(0)
podCPULimit := float64(0)
podMemLimit := float64(0)

for _, containerItem := range containers {
container, ok := containerItem.(map[string]interface{})
if !ok {
return nil, errs.Errorf("containers array contains invalid container: %v", containerItem)
}
resourcesItem, pres := container["resources"]
if !pres {
numContainersMissingCPU++
numContainersMissingMem++
continue
}
resources, ok := resourcesItem.(map[string]interface{})
if !ok {
return nil, errs.Errorf("resources spec in pod template is invalid: %v", resourcesItem)
}
if len(resources) == 0 {
numContainersMissingCPU++
numContainersMissingMem++
continue
}

limits, ok := resources["limits"].(map[string]interface{})
if !ok {
return nil, errs.Errorf("limits is missing from deployment config %s: %+v", dcName, resources)
}

cpuLimit, err := getOptionalStringValue(limits, "cpucores")
if err != nil {
return nil, err
}
if len(cpuLimit) == 0 {
numContainersMissingCPU++
} else {
cpuQuantity, err := resource.ParseQuantity(cpuLimit)
if err != nil {
return nil, errs.Errorf("could not parse cpu quantity for %+v of %s ", container, dcName)
}

cpuValue, err := quantityToFloat64(cpuQuantity)
if err != nil {
return nil, errs.Errorf("could not convert cpu quantity %+v to float64 value", cpuQuantity)
}
podCPULimit += cpuValue
}

memLimit, err := getOptionalStringValue(limits, "memory")
if err != nil {
return nil, err
}
if len(memLimit) == 0 {
numContainersMissingMem++
} else {
memoryQuantity, err := resource.ParseQuantity(memLimit)
if err != nil {
return nil, errs.Errorf("could not parse memory quantity for %+v of %s ", container, dcName)
}

memoryValue, err := quantityToFloat64(memoryQuantity)
if err != nil {
return nil, errs.Errorf("could not convert memory quantity %+v to float64 value", memoryQuantity)
}

podMemLimit += memoryValue
}
}

if numContainersMissingCPU > 0 || numContainersMissingMem > 0 {
// Look up default resource limits using LimitRanges API
limitRange, err := kc.LimitRanges(namespace).Get(limitRangeName, metaV1.GetOptions{})
if err != nil {
log.Error(nil, map[string]interface{}{
"err": err,
"namespace": namespace,
"limit_range_name": limitRangeName,
}, "failed to get limit range")
return nil, convertError(errs.WithStack(err), "failed to get limit range %s in %s", limitRangeName, namespace)
}

var containerCPULimit, containerMemLimit *resource.Quantity
for _, limit := range limitRange.Spec.Limits {
if limit.Type == "Container" {
cpuQty, pres := limit.Default[v1.ResourceCPU]
if pres {
containerCPULimit = &cpuQty
}
memQty, pres := limit.Default[v1.ResourceMemory]
if pres {
containerMemLimit = &memQty
}
}
}

if containerCPULimit == nil || containerMemLimit == nil {
log.Error(nil, map[string]interface{}{
"limit_range": limitRange,
"namespace": namespace,
}, "CPU or memory container limit missing from LimitRange")
return nil, errs.Errorf("CPU or memory container limit missing from LimitRange for namespace %s", namespace)
}

defaultCPULimit, err := quantityToFloat64(*containerCPULimit)
if err != nil {
return nil, errs.Errorf("could not convert cpu quantity %+v to float64 value", *containerCPULimit)
}
defaultMemLimit, err := quantityToFloat64(*containerMemLimit)
if err != nil {
return nil, errs.Errorf("could not convert memory quantity %+v to float64 value", *containerMemLimit)
}

// Apply default limit for each container that didn't specify CPU/memory limits
podCPULimit += defaultCPULimit * numContainersMissingCPU
podMemLimit += defaultMemLimit * numContainersMissingMem
}

return &app.SimpleDeploymentPodLimitRange{
Limits: &app.PodsQuota{
Cpucores: &podCPULimit,
Memory: &podMemLimit,
},
}, nil
}

// Pod status constants
const (
podRunning = "Running"
Expand Down
103 changes: 103 additions & 0 deletions kubernetes/deployments_kubeclient_blackbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1543,6 +1543,109 @@ func verifyDeployment(dep *app.SimpleDeployment, testCase *deployTestData, t *te
}
}

func TestGetDeploymentPodQuota(t *testing.T) {
testCases := []struct {
testName string
spaceName string
appName string
envName string
cassetteName string
expectedCPU float64
expectedMem float64
errorChecker func(error) (bool, error)
shouldFail bool
}{
{
testName: "Basic",
spaceName: "mySpace",
appName: "myApp",
envName: "run",
cassetteName: "podquota",
expectedCPU: 1,
expectedMem: 262144000,
},
{
testName: "Bad Environment",
spaceName: "mySpace",
appName: "myApp",
envName: "doesNotExist",
cassetteName: "podquota",
shouldFail: true,
},
{
testName: "Bad Deployment",
spaceName: "mySpace",
appName: "myApp",
envName: "stage",
cassetteName: "podquota",
shouldFail: true,
errorChecker: errors.IsNotFoundError,
},
{
testName: "Multi Container",
spaceName: "mySpace",
appName: "myApp",
envName: "run",
cassetteName: "podquota-multicontainer",
expectedCPU: 1.7,
expectedMem: 799014912,
},
{
testName: "No Resources",
spaceName: "mySpace",
appName: "myApp",
envName: "run",
cassetteName: "podquota-noresources",
expectedCPU: 1,
expectedMem: 536870912,
},
{
testName: "Empty Resources",
spaceName: "mySpace",
appName: "myApp",
envName: "run",
cassetteName: "podquota-emptyresources",
expectedCPU: 1,
expectedMem: 536870912,
},
{
testName: "Split Limit Range",
spaceName: "mySpace",
appName: "myApp",
envName: "run",
cassetteName: "podquota-split",
expectedCPU: 1,
expectedMem: 262144000,
},
}

for _, testCase := range testCases {
t.Run(testCase.testName, func(t *testing.T) {
r, err := recorder.New(pathToTestJSON + testCase.cassetteName)
require.NoError(t, err, "Failed to open cassette")
defer r.Stop()

fixture := &testFixture{}
kc := getDefaultKubeClient(fixture, r.Transport, t)

quota, err := kc.GetDeploymentPodQuota(testCase.spaceName, testCase.appName, testCase.envName)
if testCase.shouldFail {
require.Error(t, err, "Expected an error")
if testCase.errorChecker != nil {
matches, _ := testCase.errorChecker(err)
require.True(t, matches, "Error or cause must be the expected type")
}
} else {
require.NoError(t, err, "Unexpected error occurred")
require.NotNil(t, quota.Limits.Cpucores, "CPU limits must be non-nil")
require.InDelta(t, testCase.expectedCPU, *quota.Limits.Cpucores, fltDelta, "CPU limits are incorrect")
require.NotNil(t, quota.Limits.Memory, "Memory limits must be non-nil")
require.InDelta(t, testCase.expectedMem, *quota.Limits.Memory, fltDelta, "Memory limits are incorrect")
}
})
}
}

// code for test URL provider

func (up *testURLProvider) GetAPIToken() (*string, error) {
Expand Down
Loading

0 comments on commit cdef2ae

Please sign in to comment.