Skip to content

Commit

Permalink
feat(provider/kubernetes): support for kubectl server-side-apply stra…
Browse files Browse the repository at this point in the history
…tegy (spinnaker#5989)

* feat(provider/kubernetes): support for kubectl server-side-apply strategy

kubernetes server-side apply (SSA) was released back in 1.14 and became GA In 1.22. This new strategy will use the new merging algorithm, as well as tracking field ownership at the kubernetes api-server

Signed-off-by: Amir Alavi <[email protected]>

* server-side-apply: support force-conflicts

Signed-off-by: Amir Alavi <[email protected]>

---------

Signed-off-by: Amir Alavi <[email protected]>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
2 people authored and aman-agrawal committed Jul 11, 2024
1 parent 32a4a75 commit 794b5bf
Show file tree
Hide file tree
Showing 8 changed files with 390 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1776,4 +1776,159 @@ public void shouldUseSourceCapacityVersioned() throws IOException, InterruptedEx
+ " -o=jsonpath='{.spec.template.spec.containers[0].env[0].value}'");
assertEquals("test", envVarValue, "Expected update env var for " + appName + " replicaset.\n");
}

@DisplayName(
".\n===\n"
+ "Given a deployment manifest with server-side-apply strategy set\n"
+ "When sending deploy manifest request\n"
+ "Then a deployment is created using server-side apply\n===")
@Test
public void shouldDeployUsingServerSideApply() throws IOException, InterruptedException {
// ------------------------- given --------------------------
String appName = "server-side-apply";
System.out.println("> Using namespace: " + account1Ns + ", appName: " + appName);
List<Map<String, Object>> manifest =
KubeTestUtils.loadYaml("classpath:manifests/deployment.yml")
.withValue("metadata.namespace", account1Ns)
.withValue("metadata.name", DEPLOYMENT_1_NAME)
.withValue(
"metadata.annotations",
ImmutableMap.of("strategy.spinnaker.io/server-side-apply", "force-conflicts"))
.asList();

// ------------------------- when --------------------------
List<Map<String, Object>> body =
KubeTestUtils.loadJson("classpath:requests/deploy_manifest.json")
.withValue("deployManifest.account", ACCOUNT1_NAME)
.withValue("deployManifest.moniker.app", appName)
.withValue("deployManifest.manifests", manifest)
.asList();
KubeTestUtils.deployAndWaitStable(
baseUrl(), body, account1Ns, "deployment " + DEPLOYMENT_1_NAME);

// ------------------------- then --------------------------
/* Expecting:
metadata:
managedFields:
- manager: kubectl
operation: Apply
fieldsType: FieldsV1
*/
String managedFields =
kubeCluster.execKubectl(
"-n "
+ account1Ns
+ " get deployment "
+ DEPLOYMENT_1_NAME
+ " -o=jsonpath='{.metadata.managedFields}'");
assertTrue(
Strings.isNotEmpty(managedFields),
"Expected managedFields for "
+ DEPLOYMENT_1_NAME
+ " deployment to exist and be managed server-side. managedFields:\n"
+ managedFields);

String applyManager =
kubeCluster.execKubectl(
"-n "
+ account1Ns
+ " get deployment "
+ DEPLOYMENT_1_NAME
+ " -o=jsonpath='{.metadata.managedFields[?(@.operation==\"Apply\")].manager}'");
assertEquals(
"kubectl",
applyManager,
"Expected apply manager for "
+ DEPLOYMENT_1_NAME
+ " deployment to be managed server-side. managedFields:\n"
+ managedFields);
}

@DisplayName(
".\n===\n"
+ "Given a deployment manifest with server-side-apply disabled set\n"
+ "When sending deploy manifest request\n"
+ "Then a deployment is created using client-side apply\n===")
@Test
public void shouldDeployUsingApplyWithServerSideApplyDisabled()
throws IOException, InterruptedException {
// ------------------------- given --------------------------
String appName = "server-side-apply-disabled";
System.out.println("> Using namespace: " + account1Ns + ", appName: " + appName);
List<Map<String, Object>> manifest =
KubeTestUtils.loadYaml("classpath:manifests/deployment.yml")
.withValue("metadata.namespace", account1Ns)
.withValue("metadata.name", DEPLOYMENT_1_NAME)
.withValue(
"metadata.annotations",
ImmutableMap.of("strategy.spinnaker.io/server-side-apply", "false"))
.asList();

// ------------------------- when --------------------------
List<Map<String, Object>> body =
KubeTestUtils.loadJson("classpath:requests/deploy_manifest.json")
.withValue("deployManifest.account", ACCOUNT1_NAME)
.withValue("deployManifest.moniker.app", appName)
.withValue("deployManifest.manifests", manifest)
.asList();
KubeTestUtils.deployAndWaitStable(
baseUrl(), body, account1Ns, "deployment " + DEPLOYMENT_1_NAME);

// ------------------------- then --------------------------
String lastAppliedConfiguration =
kubeCluster.execKubectl(
"-n "
+ account1Ns
+ " get deployment "
+ DEPLOYMENT_1_NAME
+ " -o=jsonpath='{.metadata.annotations.kubectl\\.kubernetes\\.io/last-applied-configuration}'");
assertTrue(
Strings.isNotEmpty(lastAppliedConfiguration),
"Expected last-applied-configuration for "
+ DEPLOYMENT_1_NAME
+ " deployment to exist and be managed client-side. fields:\n"
+ lastAppliedConfiguration);
}

@DisplayName(
".\n===\n"
+ "Given a deployment manifest without a strategy set\n"
+ "When sending deploy manifest request\n"
+ "Then a deployment is created using client-side apply\n===")
@Test
public void shouldDeployUsingClientApply() throws IOException, InterruptedException {
// ------------------------- given --------------------------
String appName = "client-side-apply";
System.out.println("> Using namespace: " + account1Ns + ", appName: " + appName);
List<Map<String, Object>> manifest =
KubeTestUtils.loadYaml("classpath:manifests/deployment.yml")
.withValue("metadata.namespace", account1Ns)
.withValue("metadata.name", DEPLOYMENT_1_NAME)
.asList();

// ------------------------- when --------------------------
List<Map<String, Object>> body =
KubeTestUtils.loadJson("classpath:requests/deploy_manifest.json")
.withValue("deployManifest.account", ACCOUNT1_NAME)
.withValue("deployManifest.moniker.app", appName)
.withValue("deployManifest.manifests", manifest)
.asList();
KubeTestUtils.deployAndWaitStable(
baseUrl(), body, account1Ns, "deployment " + DEPLOYMENT_1_NAME);

// ------------------------- then --------------------------
String lastAppliedConfiguration =
kubeCluster.execKubectl(
"-n "
+ account1Ns
+ " get deployment "
+ DEPLOYMENT_1_NAME
+ " -o=jsonpath='{.metadata.annotations.kubectl\\.kubernetes\\.io/last-applied-configuration}'");
assertTrue(
Strings.isNotEmpty(lastAppliedConfiguration),
"Expected last-applied-configuration for "
+ DEPLOYMENT_1_NAME
+ " deployment to exist and be managed client-side. fields:\n"
+ lastAppliedConfiguration);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,29 +40,38 @@ public final class KubernetesManifestStrategy {
private static final String USE_SOURCE_CAPACITY =
STRATEGY_ANNOTATION_PREFIX + "/use-source-capacity";

private static final String SERVER_SIDE_APPLY_STRATEGY =
STRATEGY_ANNOTATION_PREFIX + "/server-side-apply";
private static final String SERVER_SIDE_APPLY_FORCE_CONFLICTS = "force-conflicts";

private final DeployStrategy deployStrategy;
private final Versioned versioned;
private final OptionalInt maxVersionHistory;
private final boolean useSourceCapacity;
private final ServerSideApplyStrategy serverSideApplyStrategy;

@Builder
@ParametersAreNullableByDefault
private KubernetesManifestStrategy(
DeployStrategy deployStrategy,
Versioned versioned,
Integer maxVersionHistory,
boolean useSourceCapacity) {
boolean useSourceCapacity,
ServerSideApplyStrategy serverSideApplyStrategy) {
this.deployStrategy = Optional.ofNullable(deployStrategy).orElse(DeployStrategy.APPLY);
this.versioned = Optional.ofNullable(versioned).orElse(Versioned.DEFAULT);
this.maxVersionHistory =
maxVersionHistory == null ? OptionalInt.empty() : OptionalInt.of(maxVersionHistory);
this.useSourceCapacity = useSourceCapacity;
this.serverSideApplyStrategy =
Optional.ofNullable(serverSideApplyStrategy).orElse(ServerSideApplyStrategy.DEFAULT);
}

static KubernetesManifestStrategy fromAnnotations(Map<String, String> annotations) {
return KubernetesManifestStrategy.builder()
.versioned(Versioned.fromAnnotations(annotations))
.deployStrategy(DeployStrategy.fromAnnotations(annotations))
.serverSideApplyStrategy(ServerSideApplyStrategy.fromAnnotations(annotations))
.useSourceCapacity(Boolean.parseBoolean(annotations.get(USE_SOURCE_CAPACITY)))
.maxVersionHistory(Ints.tryParse(annotations.getOrDefault(MAX_VERSION_HISTORY, "")))
.build();
Expand All @@ -72,6 +81,7 @@ ImmutableMap<String, String> toAnnotations() {
ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
builder.putAll(deployStrategy.toAnnotations());
builder.putAll(versioned.toAnnotations());
builder.putAll(serverSideApplyStrategy.toAnnotations());
if (maxVersionHistory.isPresent()) {
builder.put(MAX_VERSION_HISTORY, Integer.toString(maxVersionHistory.getAsInt()));
}
Expand Down Expand Up @@ -107,7 +117,8 @@ ImmutableMap<String, String> toAnnotations() {
public enum DeployStrategy {
APPLY(null),
RECREATE(STRATEGY_ANNOTATION_PREFIX + "/recreate"),
REPLACE(STRATEGY_ANNOTATION_PREFIX + "/replace");
REPLACE(STRATEGY_ANNOTATION_PREFIX + "/replace"),
SERVER_SIDE_APPLY(SERVER_SIDE_APPLY_STRATEGY);

@Nullable private final String annotation;

Expand All @@ -122,6 +133,11 @@ static DeployStrategy fromAnnotations(Map<String, String> annotations) {
if (Boolean.parseBoolean(annotations.get(REPLACE.annotation))) {
return REPLACE;
}
if (annotations.containsKey(SERVER_SIDE_APPLY.annotation)
&& ServerSideApplyStrategy.fromAnnotations(annotations)
!= ServerSideApplyStrategy.DISABLED) {
return SERVER_SIDE_APPLY;
}
return APPLY;
}

Expand All @@ -142,4 +158,33 @@ void setAnnotations(Map<String, String> annotations) {
annotations.putAll(toAnnotations());
}
}

public enum ServerSideApplyStrategy {
FORCE_CONFLICTS(ImmutableMap.of(SERVER_SIDE_APPLY_STRATEGY, SERVER_SIDE_APPLY_FORCE_CONFLICTS)),
DISABLED(ImmutableMap.of(SERVER_SIDE_APPLY_STRATEGY, Boolean.FALSE.toString())),
DEFAULT(ImmutableMap.of());
private final ImmutableMap<String, String> annotations;

ServerSideApplyStrategy(ImmutableMap<String, String> annotations) {
this.annotations = annotations;
}

static ServerSideApplyStrategy fromAnnotations(Map<String, String> annotations) {
if (annotations.containsKey(SERVER_SIDE_APPLY_STRATEGY)) {
String strategy = annotations.get(SERVER_SIDE_APPLY_STRATEGY);
if (Boolean.parseBoolean(strategy)) {
return DEFAULT;
}

if (strategy.equals(SERVER_SIDE_APPLY_FORCE_CONFLICTS)) {
return FORCE_CONFLICTS;
}
}
return DISABLED;
}

ImmutableMap<String, String> toAnnotations() {
return annotations;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@
import com.netflix.spinnaker.clouddriver.kubernetes.security.KubernetesCredentials;
import com.netflix.spinnaker.clouddriver.kubernetes.security.KubernetesSelectorList;
import io.kubernetes.client.openapi.models.V1DeleteOptions;
import java.util.ArrayList;
import java.util.List;

public interface CanDeploy {
default OperationResult deploy(
KubernetesCredentials credentials,
KubernetesManifest manifest,
KubernetesManifestStrategy.DeployStrategy deployStrategy,
KubernetesManifestStrategy.ServerSideApplyStrategy serverSideApplyStrategy,
Task task,
String opName) {
// If the manifest has a generateName, we must apply with kubectl create as all other operations
Expand Down Expand Up @@ -59,6 +62,16 @@ default OperationResult deploy(
case REPLACE:
deployedManifest = credentials.createOrReplace(manifest, task, opName);
break;
case SERVER_SIDE_APPLY:
List<String> cmdArgs = new ArrayList<>();
cmdArgs.add("--server-side=true");
if (serverSideApplyStrategy.equals(
KubernetesManifestStrategy.ServerSideApplyStrategy.FORCE_CONFLICTS)) {
cmdArgs.add("--force-conflicts=true");
}
deployedManifest =
credentials.deploy(manifest, task, opName, cmdArgs.toArray(new String[cmdArgs.size()]));
break;
case APPLY:
deployedManifest = credentials.deploy(manifest, task, opName);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -587,12 +587,17 @@ public ImmutableList<KubernetesManifest> list(
}

public KubernetesManifest deploy(
KubernetesCredentials credentials, KubernetesManifest manifest, Task task, String opName) {
KubernetesCredentials credentials,
KubernetesManifest manifest,
Task task,
String opName,
String... cmdArgs) {
log.info("Deploying manifest {}", manifest.getFullResourceName());
List<String> command = kubectlAuthPrefix(credentials);

// Read from stdin
command.add("apply");
command.addAll(List.of(cmdArgs));
command.add("-o");
command.add("json");
command.add("-f");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,12 @@ public OperationResult operate(List<OperationResult> _unused) {
+ " to kubernetes master...");
result.merge(
deployer.deploy(
credentials, holder.manifest, strategy.getDeployStrategy(), getTask(), OP_NAME));
credentials,
holder.manifest,
strategy.getDeployStrategy(),
strategy.getServerSideApplyStrategy(),
getTask(),
OP_NAME));

result.getCreatedArtifacts().add(holder.artifact);
getTask()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -559,12 +559,13 @@ public Collection<KubernetesPodMetric> topPod(KubernetesCoordinates coords) {
() -> jobExecutor.topPod(this, coords.getNamespace(), coords.getName()));
}

public KubernetesManifest deploy(KubernetesManifest manifest, Task task, String opName) {
public KubernetesManifest deploy(
KubernetesManifest manifest, Task task, String opName, String... cmdArgs) {
return runAndRecordMetrics(
"deploy",
manifest.getKind(),
manifest.getNamespace(),
() -> jobExecutor.deploy(this, manifest, task, opName));
() -> jobExecutor.deploy(this, manifest, task, opName, cmdArgs));
}

private KubernetesManifest replace(KubernetesManifest manifest, Task task, String opName) {
Expand Down
Loading

0 comments on commit 794b5bf

Please sign in to comment.