Skip to content

Commit 3b120a9

Browse files
Merge pull request #2275 from HubSpot/deploy-validation
Prevent accidental large scale downs
2 parents 74279ca + bc6cf26 commit 3b120a9

9 files changed

Lines changed: 139 additions & 27 deletions

File tree

SingularityClient/src/main/java/com/hubspot/singularity/client/SingularityClient.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1160,7 +1160,25 @@ public SingularityRequestParent createDeployForSingularityRequest(
11601160
SingularityDeploy pendingDeploy,
11611161
Optional<Boolean> deployUnpause,
11621162
Optional<String> message,
1163-
Optional<SingularityRequest> updatedRequest
1163+
Optional<Boolean> largeScaleDownAcknowledged
1164+
) {
1165+
return createDeployForSingularityRequest(
1166+
requestId,
1167+
pendingDeploy,
1168+
deployUnpause,
1169+
message,
1170+
Optional.empty(),
1171+
largeScaleDownAcknowledged
1172+
);
1173+
}
1174+
1175+
public SingularityRequestParent createDeployForSingularityRequest(
1176+
String requestId,
1177+
SingularityDeploy pendingDeploy,
1178+
Optional<Boolean> deployUnpause,
1179+
Optional<String> message,
1180+
Optional<SingularityRequest> updatedRequest,
1181+
Optional<Boolean> largeScaleDownAcknowledged
11641182
) {
11651183
final Function<String, String> requestUri = (String host) ->
11661184
String.format(DEPLOYS_FORMAT, getApiBase(host));
@@ -1178,6 +1196,10 @@ public SingularityRequestParent createDeployForSingularityRequest(
11781196
message,
11791197
updatedRequest
11801198
)
1199+
),
1200+
Collections.singletonMap(
1201+
"largeScaleDownAcknowledged",
1202+
largeScaleDownAcknowledged.orElse(false)
11811203
)
11821204
);
11831205

SingularityService/src/main/java/com/hubspot/singularity/config/SingularityConfiguration.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ public class SingularityConfiguration extends Configuration {
237237

238238
private int maxUserIdSize = 100;
239239

240+
private int maxScaleDownWithoutAcknowledgement = 10;
241+
240242
private boolean storeAllMesosTaskInfoForDebugging = false;
241243

242244
@JsonProperty("historyPurging")
@@ -832,6 +834,10 @@ public int getMaxUserIdSize() {
832834
return maxUserIdSize;
833835
}
834836

837+
public int getMaxScaleDownWithoutAcknowledgement() {
838+
return maxScaleDownWithoutAcknowledgement;
839+
}
840+
835841
public int getMaxTasksPerOffer() {
836842
return maxTasksPerOffer;
837843
}
@@ -1245,6 +1251,12 @@ public void setMaxTasksPerOfferPerRequest(int maxTasksPerOfferPerRequest) {
12451251
this.maxTasksPerOfferPerRequest = maxTasksPerOfferPerRequest;
12461252
}
12471253

1254+
public void setMaxScaleDownWithoutAcknowledgement(
1255+
int maxScaleDownWithoutAcknowledgement
1256+
) {
1257+
this.maxScaleDownWithoutAcknowledgement = maxScaleDownWithoutAcknowledgement;
1258+
}
1259+
12481260
public void setMesosConfiguration(MesosConfiguration mesosConfiguration) {
12491261
this.mesosConfiguration = mesosConfiguration;
12501262
}

SingularityService/src/main/java/com/hubspot/singularity/data/SingularityValidator.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1209,7 +1209,11 @@ private boolean isAllowBounceToSameHost(SingularityRequest request) {
12091209
}
12101210
}
12111211

1212-
public void checkScale(SingularityRequest request, Optional<Integer> previousScale) {
1212+
public void checkScale(
1213+
SingularityRequest request,
1214+
Optional<Integer> previousScale,
1215+
Optional<Boolean> largeScaleDownAcknowledged
1216+
) {
12131217
AgentPlacement placement = request.getAgentPlacement().orElse(defaultAgentPlacement);
12141218

12151219
if (placement != AgentPlacement.GREEDY && placement != AgentPlacement.OPTIMISTIC) {
@@ -1239,6 +1243,25 @@ public void checkScale(SingularityRequest request, Optional<Integer> previousSca
12391243
);
12401244
}
12411245
}
1246+
1247+
if (previousScale.isPresent() && !largeScaleDownAcknowledged.orElse(false)) {
1248+
int absMaxScaleDown = singularityConfiguration.getMaxScaleDownWithoutAcknowledgement();
1249+
boolean scaleDownExceedsAbsoluteMax =
1250+
previousScale.get() - request.getInstancesSafe() > absMaxScaleDown;
1251+
boolean scaleDownExceedsRelativeMax =
1252+
request.getInstancesSafe() < (previousScale.get() / 2);
1253+
checkBadRequest(
1254+
!scaleDownExceedsAbsoluteMax,
1255+
"Cannot scale down by more than %s instances at a time without explicit " +
1256+
"acknowledgement (set the largeScaleDownAcknowledged field in the request)",
1257+
absMaxScaleDown
1258+
);
1259+
checkBadRequest(
1260+
!(previousScale.get() > absMaxScaleDown && scaleDownExceedsRelativeMax),
1261+
"Cannot scale down by more than half of current instances without explicit " +
1262+
"acknowledgement (set the largeScaleDownAcknowledged field in the request)"
1263+
);
1264+
}
12421265
}
12431266

12441267
public void validateExpiringMachineStateChange(

SingularityService/src/main/java/com/hubspot/singularity/resources/DeployResource.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import static com.hubspot.singularity.WebExceptions.checkNotNullBadRequest;
66

77
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import com.google.common.annotations.VisibleForTesting;
89
import com.google.inject.Inject;
910
import com.hubspot.jackson.jaxrs.PropertyFiltering;
1011
import com.hubspot.singularity.DeployState;
@@ -56,6 +57,7 @@
5657
import javax.ws.rs.Path;
5758
import javax.ws.rs.PathParam;
5859
import javax.ws.rs.Produces;
60+
import javax.ws.rs.QueryParam;
5961
import javax.ws.rs.core.Context;
6062
import javax.ws.rs.core.MediaType;
6163
import org.apache.curator.framework.recipes.leader.LeaderLatch;
@@ -131,19 +133,29 @@ public SingularityRequestParent deploy(
131133
@RequestBody(
132134
required = true,
133135
description = "Deploy data"
134-
) SingularityDeployRequest deployRequest
136+
) SingularityDeployRequest deployRequest,
137+
@QueryParam("largeScaleDownAcknowledged") Optional<Boolean> largeScaleDownAcknowledged
135138
) {
136139
return maybeProxyToLeader(
137140
requestContext,
138141
SingularityRequestParent.class,
139142
deployRequest,
140-
() -> deploy(deployRequest, user)
143+
() -> deploy(deployRequest, user, largeScaleDownAcknowledged)
141144
);
142145
}
143146

147+
@VisibleForTesting
144148
public SingularityRequestParent deploy(
145149
SingularityDeployRequest deployRequest,
146150
SingularityUser user
151+
) {
152+
return deploy(deployRequest, user, Optional.empty());
153+
}
154+
155+
public SingularityRequestParent deploy(
156+
SingularityDeployRequest deployRequest,
157+
SingularityUser user,
158+
Optional<Boolean> largeScaleDownAcknowledged
147159
) {
148160
validator.checkActionEnabled(SingularityAction.DEPLOY);
149161
SingularityDeploy deploy = deployRequest.getDeploy();
@@ -197,7 +209,8 @@ public SingularityRequestParent deploy(
197209

198210
validator.checkScale(
199211
request,
200-
Optional.of(taskManager.getActiveTaskIdsForRequest(request.getId()).size())
212+
Optional.of(taskManager.getActiveTaskIdsForRequest(request.getId()).size()),
213+
largeScaleDownAcknowledged
201214
);
202215

203216
if (

SingularityService/src/main/java/com/hubspot/singularity/resources/RequestResource.java

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,8 @@ private void submitRequest(
168168
Optional<Boolean> skipHealthchecks,
169169
Optional<String> message,
170170
Optional<SingularityBounceRequest> maybeBounceRequest,
171-
SingularityUser user
171+
SingularityUser user,
172+
Optional<Boolean> largeScaleDownAcknowledged
172173
) {
173174
checkNotNullBadRequest(request.getId(), "Request must have an id");
174175
checkConflict(
@@ -224,11 +225,14 @@ private void submitRequest(
224225
request.toBuilder().setInstances(Optional.of(currentActiveAgentCount)).build();
225226
}
226227

227-
if (
228-
!oldRequest.isPresent() ||
229-
!(oldRequest.get().getInstancesSafe() == request.getInstancesSafe())
230-
) {
231-
validator.checkScale(request, Optional.empty());
228+
if (!oldRequest.isPresent()) {
229+
validator.checkScale(request, Optional.empty(), largeScaleDownAcknowledged);
230+
} else if (!(oldRequest.get().getInstancesSafe() == request.getInstancesSafe())) {
231+
validator.checkScale(
232+
request,
233+
Optional.of(oldRequest.get().getInstancesSafe()),
234+
largeScaleDownAcknowledged
235+
);
232236
}
233237

234238
authorizationHelper.checkForAuthorization(
@@ -383,7 +387,8 @@ public SingularityRequestParent postRequest(
383387
Optional.empty(),
384388
Optional.empty(),
385389
Optional.empty(),
386-
user
390+
user,
391+
Optional.empty()
387392
);
388393
return fillEntireRequest(fetchRequestWithState(request.getId(), user));
389394
}
@@ -463,7 +468,8 @@ private SingularityRequestParent updateAuthorizedGroups(
463468
Optional.empty(),
464469
updateGroupsRequest.getMessage(),
465470
Optional.empty(),
466-
user
471+
user,
472+
Optional.empty()
467473
);
468474
return fillEntireRequest(fetchRequestWithState(requestId, user));
469475
}
@@ -1735,7 +1741,8 @@ public SingularityRequestParent setPriority(
17351741
Optional.of(false),
17361742
Optional.of(message),
17371743
Optional.empty(),
1738-
user
1744+
user,
1745+
Optional.empty()
17391746
);
17401747

17411748
if (priorityRequest.getDurationMillis().isPresent()) {
@@ -1775,20 +1782,30 @@ public SingularityRequestParent scale(
17751782
@RequestBody(
17761783
required = true,
17771784
description = "Object to hold number of instances to request"
1778-
) SingularityScaleRequest scaleRequest
1785+
) SingularityScaleRequest scaleRequest,
1786+
@QueryParam("largeScaleDownAcknowledged") Optional<Boolean> largeScaleDownAcknowledged
17791787
) {
17801788
return maybeProxyToLeader(
17811789
requestContext,
17821790
SingularityRequestParent.class,
17831791
scaleRequest,
1784-
() -> scale(requestId, scaleRequest, user)
1792+
() -> scale(requestId, scaleRequest, user, largeScaleDownAcknowledged)
17851793
);
17861794
}
17871795

17881796
public SingularityRequestParent scale(
17891797
String requestId,
17901798
SingularityScaleRequest scaleRequest,
17911799
SingularityUser user
1800+
) {
1801+
return scale(requestId, scaleRequest, user, Optional.empty());
1802+
}
1803+
1804+
public SingularityRequestParent scale(
1805+
String requestId,
1806+
SingularityScaleRequest scaleRequest,
1807+
SingularityUser user,
1808+
Optional<Boolean> largeScaleDownAcknowledged
17921809
) {
17931810
SingularityRequestWithState oldRequestWithState = fetchRequestWithState(
17941811
requestId,
@@ -1808,7 +1825,11 @@ public SingularityRequestParent scale(
18081825
.toBuilder()
18091826
.setInstances(scaleRequest.getInstances())
18101827
.build();
1811-
validator.checkScale(newRequest, Optional.<Integer>empty());
1828+
validator.checkScale(
1829+
newRequest,
1830+
oldRequest.getInstances(),
1831+
largeScaleDownAcknowledged
1832+
);
18121833

18131834
checkBadRequest(
18141835
oldRequest.getInstancesSafe() != newRequest.getInstancesSafe(),
@@ -1871,7 +1892,8 @@ public SingularityRequestParent scale(
18711892
scaleRequest.getSkipHealthchecks(),
18721893
Optional.of(scaleMessage),
18731894
Optional.of(bounceRequest),
1874-
user
1895+
user,
1896+
largeScaleDownAcknowledged
18751897
);
18761898
} else {
18771899
submitRequest(
@@ -1881,7 +1903,8 @@ public SingularityRequestParent scale(
18811903
scaleRequest.getSkipHealthchecks(),
18821904
Optional.of(scaleMessage),
18831905
Optional.empty(),
1884-
user
1906+
user,
1907+
largeScaleDownAcknowledged
18851908
);
18861909
}
18871910

@@ -2117,7 +2140,8 @@ public SingularityRequestParent skipHealthchecks(
21172140
Optional.empty(),
21182141
skipHealthchecksRequest.getMessage(),
21192142
Optional.empty(),
2120-
user
2143+
user,
2144+
Optional.empty()
21212145
);
21222146

21232147
if (skipHealthchecksRequest.getDurationMillis().isPresent()) {

SingularityService/src/test/java/com/hubspot/singularity/scheduler/SingularitySchedulerTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2338,7 +2338,7 @@ public void testBounceReleasesLockWithAlternateCleanupType() {
23382338
@Test
23392339
public void testIncrementalBounce() {
23402340
initRequest();
2341-
resourceOffers(2); // set up agents so scale validate will pass
2341+
resourceOffers(3); // set up agents so scale validate will pass
23422342

23432343
SingularityRequest request = requestResource
23442344
.getRequest(requestId, singularityUser)

SingularityUI/app/actions/api/requests.es6

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,8 @@ export const PersistSkipRequestHealthchecks = buildJsonApiAction(
137137
export const ScaleRequest = buildJsonApiAction(
138138
'SCALE_REQUEST',
139139
'PUT',
140-
(requestId, {instances, skipHealthchecks, durationMillis, message, actionId, bounce, incremental }) => ({
141-
url: `/requests/request/${requestId}/scale`,
140+
(requestId, {instances, skipHealthchecks, durationMillis, message, actionId, bounce, incremental, largeScaleDownAcknowledged }) => ({
141+
url: `/requests/request/${requestId}/scale?largeScaleDownAcknowledged=${largeScaleDownAcknowledged}`,
142142
body: { instances, skipHealthchecks, durationMillis, message, actionId, bounce, incremental }
143143
})
144144
);

SingularityUI/app/components/common/modal/FormModal.jsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,17 @@ export default class FormModal extends React.Component {
4545
}
4646

4747
static FormItem = (props) => {
48-
if ((props.element.dependsOn && props.formState[props.element.dependsOn]) || !props.element.dependsOn) {
48+
const noDepends = (!props.element.dependsOn && !props.element.dependsOnFormState);
49+
const dependsOnOk = (props.element.dependsOn && props.formState[props.element.dependsOn]);
50+
const dependsOnFormStateOk = (props.element.dependsOnFormState && props.element.dependsOnFormState(props.formState));
51+
if (noDepends || dependsOnOk || dependsOnFormStateOk) {
4952
return (
5053
<div className={classNames(props.className, {'childItem': props.formState[props.element.dependsOn]})}>
5154
{props.children}
5255
</div>
5356
);
5457
}
58+
5559
return null;
5660
};
5761

@@ -502,6 +506,7 @@ FormModal.propTypes = {
502506
values: React.PropTypes.array,
503507
defaultValue: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.bool, React.PropTypes.number, React.PropTypes.array]),
504508
validateField: React.PropTypes.func, // String -> String, return field validation error or falsey value if valid
505-
dependsOn: React.PropTypes.string // Only show this item if the other item (referenced by name) has a truthy value
509+
dependsOn: React.PropTypes.string, // Only show this item if the other item (referenced by name) has a truthy value
510+
dependsOnFormState: React.PropTypes.func, // Only show this item if this function applied to form state returns a truthy value
506511
})).isRequired
507512
};

SingularityUI/app/components/common/modalButtons/ScaleModal.jsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,16 @@ class ScaleModal extends Component {
3939
}
4040

4141
handleScale(data) {
42-
const { instances, durationMillis, message, bounce, incremental } = data;
42+
const { instances, durationMillis, message, bounce, incremental, largeScaleDownAcknowledged } = data;
4343
const isIncremental = incremental === 'incremental';
4444
this.props.scaleRequest(
4545
{
4646
instances,
4747
durationMillis,
4848
message,
4949
bounce,
50-
incremental: isIncremental
50+
incremental: isIncremental,
51+
largeScaleDownAcknowledged
5152
}
5253
);
5354
}
@@ -95,6 +96,18 @@ class ScaleModal extends Component {
9596
name: 'message',
9697
type: FormModal.INPUT_TYPES.STRING,
9798
label: 'Message: (optional)'
99+
},
100+
{
101+
name: 'largeScaleDownAcknowledged',
102+
type: FormModal.INPUT_TYPES.BOOLEAN,
103+
label: 'Explciit acknowledgement of large scale down (less than -10 or 1/2 of previous)',
104+
defaultValue: false,
105+
dependsOnFormState: data => {
106+
const scaleDownExceedsAbsoluteMax = (this.props.currentInstances - data.instances) > 10;
107+
const scaleDownExceedsRelativeMax = (this.props.currentInstances > 10) && (data.instances < (this.props.currentInstances / 2));
108+
// window.config flag in case the numbers change at some point
109+
return scaleDownExceedsAbsoluteMax || scaleDownExceedsRelativeMax || window.config.largeScaleDownAcknowledged;
110+
},
98111
}
99112
]}>
100113
<p>Scaling request:</p>

0 commit comments

Comments
 (0)