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(provider/kubernetes): support for kubectl server-side-apply strategy #5989

Merged
merged 3 commits into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1785,4 +1785,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 @@ -561,12 +561,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