Commit 06471dca authored by Christos Tranoris's avatar Christos Tranoris
Browse files

adding ArgoCD class

parent 0b6c2642
Loading
Loading
Loading
Loading
Loading
+292 −0
Original line number Diff line number Diff line
package org.etsi.osl.cridge.simulation;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import io.fabric8.kubernetes.api.model.GenericKubernetesResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

/**
 * The ArgoCD K8aaS special case carved out of {@link SimulatedKubernetesClientResource}: given a
 * simulated deploy of an ArgoCD {@code Application} CR whose Helm chart provisions a
 * Terraform-controller-backed Kubernetes cluster, fabricate the planning resources for the Kubernetes
 * objects that deployment eventually leaves in {@code spec.destination.namespace}.
 *
 * <p>Kept separate from {@link SimulatedKubernetesClientResource} because it is a single, narrow
 * pattern-match that has nothing to do with the generic "reply to a CR deploy/patch/delete dry-run"
 * contract that class implements - so each has one reason to change and this can be tested on its
 * own. {@link SimulatedKubernetesClientResource#deployCR} calls
 * {@link #reportNamespaceSideEffectResourcesIfApplicable} once, unconditionally; the applicability
 * check lives here.
 *
 * <p>K8aaS pattern: an ArgoCD {@code Application} CR whose Helm chart provisions a
 * Terraform-controller-backed Kubernetes cluster into {@code spec.destination.namespace} always
 * ends up populating that same namespace once the real cluster comes up - production's
 * {@code NamespaceWatcher} would eventually mirror what lands there into the catalog
 * ({@code KubernetesSecret2OpensliceResource}/{@code KubernetesConfigMap2OpensliceResource}) the
 * moment it appears for real. Nothing ever appears for real in a simulation, so this class
 * fabricates that eventual outcome as extra planning resources whenever it recognizes this shape.
 *
 * <p>Scoped specifically to {@link #ARGOCD_K8AAS_CHART}, not every ArgoCD {@code Application}: an
 * Application deploying some other chart (an ordinary workload, say) never provisions a cluster and
 * so never produces these as a side effect - fabricating them for it would be wrong, not merely
 * imprecise.
 */
@Component
public class SimulatedArgoCdK8aasResources {

  private static final Logger logger = LoggerFactory.getLogger( "org.etsi.osl.cridge" );

  private static final String ARGOCD_API_VERSION = "argoproj.io/v1alpha1";
  private static final String ARGOCD_KIND_APPLICATION = "Application";
  private static final String ARGOCD_K8AAS_CHART = "provision-k8s-cluster-argocd-tf-controller";

  /**
   * The resources this chart really does leave behind in {@code spec.destination.namespace}, taken
   * from the actual catalog entries a completed K8aaS deployment produces: the Terraform controller
   * writes the {@code kubeconf} and {@code ssh-key-pair} Secrets for the provisioned cluster, and
   * Kubernetes itself auto-creates the {@code kube-root-ca.crt} ConfigMap in every namespace. Their
   * <em>names, kinds and data keys</em> are deterministic - which is exactly what makes simulating
   * them honest; only the data <em>values</em> are unknowable ahead of time, and those are never
   * fabricated (see {@link #namespacedResourceCharacteristicsFor}).
   *
   * <p>Category/description/version mirror what production's own
   * {@code KubernetesSecret}/{@code KubernetesConfigMap} ({@code org.etsi.osl.model.k8s}) set on the
   * real catalog entries.
   */
  private static final List<SimulatedNamespacedResource> K8AAS_NAMESPACE_RESOURCES = List.of(
      new SimulatedNamespacedResource("kubeconf", "Secret", "secrets", "Secret/Kubernetes/v1", "secret",
          List.of("kubeconf")),
      new SimulatedNamespacedResource("ssh-key-pair", "Secret", "secrets", "Secret/Kubernetes/v1", "secret",
          List.of("ssh-privatekey", "ssh-publickey")),
      new SimulatedNamespacedResource("kube-root-ca.crt", "ConfigMap", "configmaps",
          "ConfigMap/Kubernetes/v1", "configMap", List.of("ca.crt")));

  /** The Kubernetes API version of all of the above - they are all core/v1 objects. */
  private static final String K8S_CORE_API_VERSION = "v1";

  /**
   * Stands in for the value of a data key that will only exist once a real deployment has happened.
   * Deliberately self-describing rather than plausible-looking: a plan may show <em>that</em> a
   * kubeconfig/keypair/CA bundle will be there, but anything resembling actual credential material
   * would be a fabrication, and worse, could be mistaken downstream for a usable value.
   */
  private static final String SIMULATED_VALUE_PLACEHOLDER =
      "(simulated - value exists only after a real deployment)";

  /**
   * One resource a simulated K8aaS deployment leaves behind in the destination namespace.
   *
   * @param name            the Kubernetes object's own name, e.g. {@code kubeconf}
   * @param kind            {@code Secret} / {@code ConfigMap}
   * @param apiGroup        the plural form production reports as the {@code apiGroup} characteristic
   * @param category        the TMF Resource category production assigns, e.g. {@code Secret/Kubernetes/v1}
   * @param descriptionNoun how production words this kind in its description ("secret"/"configMap")
   * @param dataKeys        the object's own {@code data} keys, which production mirrors one-to-one as
   *                        extra characteristics - the part that genuinely differs between these three
   *                        ({@code kubeconf}; {@code ssh-privatekey}/{@code ssh-publickey};
   *                        {@code ca.crt}), fixed by what the chart and Kubernetes always write
   */
  private record SimulatedNamespacedResource(String name, String kind, String apiGroup, String category,
      String descriptionNoun, List<String> dataKeys) {
  }

  @Autowired
  private PlanningCallbackClient planningCallbackClient;

  @Value("${cridge.simulation.simulated-deploy-delay-ms:500}")
  private long simulatedDeployDelayMs;

  private final ExecutorService callbackExecutor = Executors.newCachedThreadPool();

  /**
   * Reports the resources listed in {@link #K8AAS_NAMESPACE_RESOURCES} - one planning resource each,
   * named/described/categorized exactly as the real catalog entries will be once the deployment
   * happens for real - but only when {@code gkr} is an ArgoCD {@code Application} using the K8aaS
   * chart. Any other resource is a no-op.
   *
   * <p>Deploy-only, deliberately: a callback with no {@code org.etsi.osl.resourceId} always creates a
   * brand-new {@code ExpectedResource} in OSOM's plan (see
   * {@code org.etsi.osl.osom.tentative.PlanningResourceCallbackHandler}), so firing this on every
   * {@code patchCR} of the same Application would keep inventing duplicate rows rather than updating
   * the ones already there. {@code deleteCR} is left alone too - the callback contract is
   * create/update only, there is no teardown signal for a resource that was never really tracked.
   */
  public void reportNamespaceSideEffectResourcesIfApplicable(Map<String, Object> headers,
      GenericKubernetesResource gkr) {
    if (!isArgoCdApplication(gkr) || !isProvisionK8sClusterChart(gkr)) {
      return;
    }

    Object namespaceObj = gkr.get("spec", "destination", "namespace");
    if (namespaceObj == null || namespaceObj.toString().isBlank()) {
      logger.debug("ArgoCD Application {} has no spec.destination.namespace - not simulating the "
          + "resources it would leave there", appNameOf(gkr));
      return;
    }
    String destinationNamespace = namespaceObj.toString();
    String cluster = str(headers, "currentContextCluster");
    String masterURL = str(headers, "clusterMasterURL");
    String serviceOrderId = str(headers, "org.etsi.osl.serviceOrderId");
    String serviceId = str(headers, "org.etsi.osl.serviceId");

    callbackExecutor.submit(() -> {
      // Same timing as the Application's own callback #2: these only exist, for real, once the
      // cluster the Application deploys is actually up.
      sleepQuietly(simulatedDeployDelayMs);
      for (SimulatedNamespacedResource r : K8AAS_NAMESPACE_RESOURCES) {
        planningCallbackClient.reportSideEffectResource(serviceOrderId, serviceId,
            namespacedResourceNameFor(r.name(), destinationNamespace, cluster, masterURL),
            r.category(),
            namespacedResourceDescriptionFor(r, destinationNamespace, cluster, masterURL),
            K8S_CORE_API_VERSION,
            namespacedResourceCharacteristicsFor(headers, destinationNamespace, r));
      }
    });
  }

  /**
   * Mirrors the characteristic <em>names</em> production's real
   * {@code KubernetesSecret.toResourceCreate()}/{@code KubernetesConfigMap.toResourceCreate()} would
   * populate (see {@code NamespaceWatcher}) - including each object's own data keys, which are the
   * part that genuinely differs between the three ({@code kubeconf}; {@code ssh-privatekey}/
   * {@code ssh-publickey}; {@code ca.crt}) and are just as deterministic as the object names
   * themselves.
   *
   * <p>Their <em>values</em>, though, get {@link #SIMULATED_VALUE_PLACEHOLDER}: nothing real is ever
   * created in a simulation, so emitting credential-shaped content would be actively misleading rather
   * than merely a placeholder. For the same reason the whole-object dumps ({@code data}, {@code json},
   * {@code metadata}) are left out entirely - unlike a key name, there is no honest partial form of
   * them - as is {@code UID}, which a real object only gets from the Kubernetes API server at creation
   * time.
   *
   * <p>The {@code org.etsi.osl.statusCheckFieldName} / {@code *CheckValue*} state-check characteristics, and
   * {@code org.etsi.osl.resourceId} (the Application's own resourceId - production's real K8aaS
   * secrets carry this same id, not a distinct id of the secret's own), are genuinely available, not
   * fabricated: OSOM sends them as headers on the Application's own deploy request (see the
   * class-level architecture note) - so echoing whatever the request actually carried is honest
   * propagation, same as {@code currentContextCluster} below.
   */
  private Map<String, String> namespacedResourceCharacteristicsFor(Map<String, Object> headers,
      String namespace, SimulatedNamespacedResource resource) {
    Map<String, String> characteristics = new LinkedHashMap<>();
    characteristics.put("Kind", resource.kind());
    characteristics.put("apiGroup", resource.apiGroup());
    characteristics.put("org.etsi.osl.namespace", namespace);
    characteristics.put("fullResourceName", "");
    putIfPresent(characteristics, "currentContextCluster", str(headers, "currentContextCluster"));
    putIfPresent(characteristics, "clusterMasterURL", str(headers, "clusterMasterURL"));
    putIfPresent(characteristics, "org.etsi.osl.serviceOrderId", str(headers, "org.etsi.osl.serviceOrderId"));
    putIfPresent(characteristics, "org.etsi.osl.serviceId", str(headers, "org.etsi.osl.serviceId"));
    putIfPresent(characteristics, "org.etsi.osl.prefixName", str(headers, "org.etsi.osl.prefixName"));
    // The Application's own resourceId header, not the (deliberately null) top-level resourceId param
    // above - production's real K8aaS secrets carry this same id as a characteristic (it is the
    // Application/cluster resource's id, not a distinct id of the secret's own), so echo it here too.
    putIfPresent(characteristics, "org.etsi.osl.resourceId", str(headers, "org.etsi.osl.resourceId"));
    putIfPresent(characteristics, "org.etsi.osl.statusCheckFieldName",
        str(headers, "org.etsi.osl.statusCheckFieldName"));
    putIfPresent(characteristics, "org.etsi.osl.statusCheckValueStandby",
        str(headers, "org.etsi.osl.statusCheckValueStandby"));
    putIfPresent(characteristics, "org.etsi.osl.statusCheckValueAlarm",
        str(headers, "org.etsi.osl.statusCheckValueAlarm"));
    putIfPresent(characteristics, "org.etsi.osl.statusCheckValueAvailable",
        str(headers, "org.etsi.osl.statusCheckValueAvailable"));
    putIfPresent(characteristics, "org.etsi.osl.statusCheckValueReserved",
        str(headers, "org.etsi.osl.statusCheckValueReserved"));
    putIfPresent(characteristics, "org.etsi.osl.statusCheckValueUnknown",
        str(headers, "org.etsi.osl.statusCheckValueUnknown"));
    putIfPresent(characteristics, "org.etsi.osl.statusCheckValueSuspended",
        str(headers, "org.etsi.osl.statusCheckValueSuspended"));
    // ITU-T X.731 state / health check values (production's real K8aaS secrets carry these too).
    for (String checkValueHeader : new String[] {
        "org.etsi.osl.healthCheckValueUp", "org.etsi.osl.healthCheckValuePending",
        "org.etsi.osl.healthCheckValueDown", "org.etsi.osl.healthCheckValueHeld",
        "org.etsi.osl.healthCheckValueGone", "org.etsi.osl.operStateCheckValueEnable",
        "org.etsi.osl.operStateCheckValueDisable", "org.etsi.osl.adminStateCheckValueLocked",
        "org.etsi.osl.adminStateCheckValueUnlocked", "org.etsi.osl.adminStateCheckValueShutdown",
        "org.etsi.osl.usageStateCheckValueIdle", "org.etsi.osl.usageStateCheckValueActive",
        "org.etsi.osl.usageStateCheckValueBusy" }) {
      putIfPresent(characteristics, checkValueHeader, str(headers, checkValueHeader));
    }
    // Last, as production does: the object's own data keys, one characteristic each.
    resource.dataKeys().forEach(key -> characteristics.put(key, SIMULATED_VALUE_PLACEHOLDER));
    return characteristics;
  }

  private static void putIfPresent(Map<String, String> map, String key, String value) {
    if (value != null && !value.isBlank()) {
      map.put(key, value);
    }
  }

  private boolean isArgoCdApplication(GenericKubernetesResource gkr) {
    return ARGOCD_API_VERSION.equals(gkr.getApiVersion()) && ARGOCD_KIND_APPLICATION.equals(gkr.getKind());
  }

  /**
   * See {@link #ARGOCD_K8AAS_CHART}. Reads {@code spec.source.chart} via fabric8's nested-path
   * accessor rather than a raw-text search over {@code crspec} - the CR is already parsed for the
   * {@code apiVersion}/{@code kind} check above, and matching the actual field is robust to
   * formatting (quoting, spacing) that a plain substring search over the raw YAML/JSON would be
   * sensitive to.
   */
  private boolean isProvisionK8sClusterChart(GenericKubernetesResource gkr) {
    Object chart = gkr.get("spec", "source", "chart");
    return chart != null && ARGOCD_K8AAS_CHART.equals(chart.toString());
  }

  private static String appNameOf(GenericKubernetesResource gkr) {
    return gkr.getMetadata() != null && gkr.getMetadata().getName() != null
        ? gkr.getMetadata().getName()
        : "argocd-application";
  }

  /**
   * Reproduces production's naming for a mirrored namespaced resource exactly (see
   * {@code NamespaceWatcher}: {@code "{objectName}@{namespace}@{cluster}@{masterURL}"}), so a plan's
   * row carries the same name the real catalog entry will have once the deployment happens for real.
   */
  private static String namespacedResourceNameFor(String objectName, String namespace, String cluster,
      String masterURL) {
    StringBuilder name = new StringBuilder(objectName).append('@').append(namespace);
    if (cluster != null && !cluster.isBlank()) {
      name.append('@').append(cluster);
    }
    if (masterURL != null && !masterURL.isBlank()) {
      name.append('@').append(masterURL);
    }
    return name.toString();
  }

  /**
   * Reproduces production's description wording for a mirrored namespaced resource (see
   * {@code NamespaceWatcher}: {@code "A secret in namespace %s on %s"} over a
   * {@code "{Kind}@{apiVersion}@{cluster}@{masterURL}"} base).
   */
  private static String namespacedResourceDescriptionFor(SimulatedNamespacedResource resource,
      String namespace, String cluster, String masterURL) {
    String base = String.format("%s@%s@%s@%s", resource.kind(), K8S_CORE_API_VERSION,
        cluster != null ? cluster : "", masterURL != null ? masterURL : "");
    return String.format("A %s in namespace %s on %s", resource.descriptionNoun(), namespace, base);
  }

  private static String str(Map<String, Object> headers, String key) {
    Object v = headers.get(key);
    return v != null ? v.toString() : null;
  }

  private static void sleepQuietly(long millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
    }
  }

}