Ejemplo n.º 1
0
    def test_load_gke_config_ok(self, m_google):
        """Load GKE configuration from demo kubeconfig."""
        # Skip the Google authentication part.
        m_google.return_value = (m_google, "project_id")
        m_google.token = "google token"

        # Load the K8s configuration for "gke" context.
        fname = "tests/support/kubeconf.yaml"
        ret, err = k8s.load_gke_config(fname, "gke")
        assert not err and isinstance(ret, K8sConfig)

        # The certificate will be in a temporary folder because the `requests`
        # library insists on reading it from a file. Here we load that file and
        # manually insert its value into the returned Config structure. This
        # will make the verification step below easier to read.
        ca_cert = open(ret.ca_cert, "r").read().strip()
        ret = ret._replace(ca_cert=ca_cert)

        # Verify the expected output.
        assert ret == K8sConfig(
            url="https://1.2.3.4",
            token="google token",
            ca_cert="ca.cert",
            client_cert=None,
            version="",
            name="clustername-gke",
        )

        # GKE is not the default context in the demo kubeconf file, which means
        # this must fail.
        assert k8s.load_gke_config(fname, None) == (K8sConfig(), True)

        # Try to load a Minikube context - must fail.
        assert k8s.load_gke_config(fname, "minikube") == (K8sConfig(), True)
Ejemplo n.º 2
0
    def test_incluster(self, m_getenv, tmp_path):
        """Create dummy certificate files and ensure the function loads them."""
        # Fake environment variable.
        m_getenv.return_value = "1.2.3.4"

        # Create dummy certificate files.
        fname_cert = tmp_path / "cert"
        fname_token = tmp_path / "token"

        # Must fail because neither of the files exists.
        assert k8s.load_incluster_config(fname_token,
                                         fname_cert) == (K8sConfig(), True)

        # Create the files with dummy content.
        fname_cert.write_text("cert")
        fname_token.write_text("token")

        # Now that the files exist we must get the proper Config structure.
        ret, err = k8s.load_incluster_config(fname_token, fname_cert)
        assert not err
        assert ret == K8sConfig(
            url='https://1.2.3.4',
            token="token",
            ca_cert=fname_cert,
            client_cert=None,
            version="",
            name="",
        )
Ejemplo n.º 3
0
def load_gke_config(fname: Filepath,
                    context: Optional[str],
                    disable_warnings: bool = False) -> Tuple[K8sConfig, bool]:
    """Return K8s access config for GKE cluster described in `kubeconfig`.

    Returns None if `kubeconfig` does not exist or could not be parsed.

    Inputs:
        kubconfig: str
            Name of kubeconfig file.
        context: str
            Kubeconf context. Use `None` to use default context.
        disable_warnings: bool
            Whether or not do disable GCloud warnings.

    Returns:
        Config

    """
    # Parse the kubeconfig file.
    name, user, cluster, err = load_kubeconfig(fname, context)
    if err:
        return (K8sConfig(), True)

    # Unpack the self signed certificate (Google does not register the K8s API
    # server certificate with a public CA).
    try:
        ssl_ca_cert_data = base64.b64decode(
            cluster["certificate-authority-data"])
    except KeyError:
        logit.debug(f"Context {context} in <{fname}> is not a GKE config")
        return (K8sConfig(), True)

    # Save the certificate to a temporary file. This is only necessary because
    # the requests library will need a path to the CA file - unfortunately, we
    # cannot just pass it the content.
    _, tmp = tempfile.mkstemp(text=False)
    ssl_ca_cert = Filepath(tmp)
    ssl_ca_cert.write_bytes(ssl_ca_cert_data)

    with warnings.catch_warnings(record=disable_warnings):
        try:
            cred, project_id = google.auth.default(
                scopes=['https://www.googleapis.com/auth/cloud-platform'])
            cred.refresh(google.auth.transport.requests.Request())
            token = cred.token
        except google.auth.exceptions.DefaultCredentialsError as e:
            logit.error(str(e))
            return (K8sConfig(), True)

    # Return the config data.
    logit.info("Assuming GKE cluster.")
    return K8sConfig(
        url=cluster["server"],
        token=token,
        ca_cert=ssl_ca_cert,
        client_cert=None,
        version="",
        name=cluster["name"],
    ), False
Ejemplo n.º 4
0
    def test_load_minikube_config_ok(self):
        # Load the K8s configuration for "minikube" context.
        fname = "tests/support/kubeconf.yaml"

        # Verify the expected output.
        ref = K8sConfig(
            url="https://192.168.0.177:8443",
            token="",
            ca_cert="ca.crt",
            client_cert=k8s.K8sClientCert(crt="client.crt", key="client.key"),
            version="",
            name="clustername-minikube",
        )
        expected = (ref, False)

        assert k8s.load_minikube_config(fname, "minikube") == expected

        # Function must also accept pathlib.Path instances.
        assert expected == k8s.load_minikube_config(pathlib.Path(fname), None)

        # Minikube also happens to be the default context, so not supplying an
        # explicit context must return the same information.
        assert expected == k8s.load_minikube_config(fname, None)

        # Try to load a GKE context - must fail.
        assert k8s.load_minikube_config(fname, "gke") == (K8sConfig(), True)
Ejemplo n.º 5
0
def version(k8sconfig: K8sConfig) -> Tuple[K8sConfig, bool]:
    """Return new `k8sconfig` with version number of K8s API.

    Contact the K8s API, query its version via `client` and return `k8sconfig`
    with an updated `version` field. All other field in `k8sconfig` will remain
    intact.

    Inputs:
        k8sconfig: K8sConfig

    Returns:
        K8sConfig

    """
    # Ask the K8s API for its version and check for errors.
    url = f"{k8sconfig.url}/version"
    resp, err = get(k8sconfig.client, url)
    if err or resp is None:
        logit.error(f"Could not interrogate {k8sconfig.name} ({url})")
        return (K8sConfig(), True)

    # Construct the version number of the K8s API.
    major, minor = resp['major'], resp['minor']
    version = f"{major}.{minor}"

    # If we are talking to GKE, the version string may now be "1.10+". It
    # simply indicates that GKE is running version 1.10.x. We need to remove
    # the "+" because the version string is important in `square`, for instance
    # to determines which URLs to contact, which fields are valid.
    version = version.replace("+", "")

    # Return an updated `K8sconfig` tuple.
    k8sconfig = k8sconfig._replace(version=version)
    return (k8sconfig, False)
Ejemplo n.º 6
0
def load_kind_config(fname: Filepath,
                     context: Optional[str]) -> Tuple[K8sConfig, bool]:
    """Load Kind configuration from `fname`.

    https://github.com/bsycorp/kind

    Kind is just another Minikube cluster. The only notable difference
    is that it does not store its credentials as files but directly in
    the Kubeconfig file. This function will copy those files into /tmp.

    Return None on error.

    Inputs:
        kubconfig: str
            Path to kubeconfig for Kind cluster.
        context: str
            Kubeconf context. Use `None` to use default context.

    Returns:
        Config

    """
    # Parse the kubeconfig file.
    name, user, cluster, err = load_kubeconfig(fname, context)
    if err:
        return (K8sConfig(), True)

    # Kind and Minikube use client certificates to authenticate. We need to
    # pass those to the HTTP client of our choice when we create the session.
    try:
        client_crt = base64.b64decode(user["client-certificate-data"]).decode()
        client_key = base64.b64decode(user["client-key-data"]).decode()
        client_ca = base64.b64decode(
            cluster["certificate-authority-data"]).decode()
        path = Filepath(tempfile.mkdtemp())
        p_client_crt = path / "kind-client.crt"
        p_client_key = path / "kind-client.key"
        p_ca = path / "kind.ca"
        p_client_crt.write_text(client_crt)
        p_client_key.write_text(client_key)
        p_ca.write_text(client_ca)
        client_cert = K8sClientCert(crt=p_client_crt, key=p_client_key)

        # Return the config data.
        logit.debug("Assuming Minikube/Kind cluster.")
        return K8sConfig(
            url=cluster["server"],
            token="",
            ca_cert=p_ca,
            client_cert=client_cert,
            version="",
            name=cluster["name"],
        ), False
    except KeyError:
        logit.debug(f"Context {context} in <{fname}> is not a Minikube config")
        return (K8sConfig(), True)
Ejemplo n.º 7
0
    def test_load_kind_config_invalid_context_err(self, tmp_path):
        """Gracefully abort if we cannot parse Kubeconfig."""
        # Valid Kubeconfig file but it has no "invalid" context.
        fname = "tests/support/kubeconf.yaml"
        assert k8s.load_kind_config(fname, "invalid") == (K8sConfig(), True)

        # Create a corrupt Kubeconfig file.
        fname = tmp_path / "kubeconfig"
        fname.write_text("")
        assert k8s.load_kind_config(fname, None) == (K8sConfig(), True)

        # Try to load a non-existing file.
        fname = tmp_path / "does-not-exist"
        assert k8s.load_kind_config(fname, None) == (K8sConfig(), True)
Ejemplo n.º 8
0
def make_manifest(kind: str,
                  namespace: str,
                  name: str,
                  labels: Dict[str, str] = {}) -> dict:
    # Try to find the resource `kind` and lift its associated `apiVersion`.
    apis = k8s_apis(K8sConfig(version="1.15"))
    try:
        apiVersion = apis[(kind, "")].apiVersion
    except KeyError:
        apiVersion = "v1"

    # Compile a manifest.
    manifest: Dict[str, Any]
    manifest = {
        'apiVersion': apiVersion,
        'kind': kind,
        'metadata': {
            'name': name,
            'labels': labels,
        },
        'spec': {
            'finalizers': ['kubernetes']
        },
        'garbage': 'more garbage',
    }

    # Do not include an empty label dict.
    if not labels:
        del manifest["metadata"]["labels"]

    # Only create namespace entry if one was specified.
    if namespace is not None:
        manifest['metadata']['namespace'] = namespace

    return manifest
Ejemplo n.º 9
0
    def test_wrong_conf(self):
        # Minikube
        fun = k8s.load_minikube_config
        resp = (K8sConfig(), True)
        assert fun("tests/support/invalid.yaml", None) == resp
        assert fun("tests/support/invalid.yaml", "invalid") == resp
        assert fun("tests/support/kubeconf.yaml", "invalid") == resp
        assert fun("tests/support/kubeconf_invalid.yaml", "minkube") == resp

        # GKE
        assert k8s.load_gke_config("tests/support/invalid.yaml", None) == resp
        assert k8s.load_gke_config("tests/support/invalid.yaml",
                                   "invalid") == resp
        assert k8s.load_gke_config("tests/support/kubeconf.yaml",
                                   "invalid") == resp
        assert k8s.load_gke_config("tests/support/kubeconf_invalid.yaml",
                                   "gke") == resp

        # EKS
        assert k8s.load_eks_config("tests/support/invalid.yaml", None) == resp
        assert k8s.load_eks_config("tests/support/invalid.yaml",
                                   "invalid") == resp
        assert k8s.load_eks_config("tests/support/kubeconf.yaml",
                                   "invalid") == resp
        assert k8s.load_eks_config("tests/support/kubeconf_invalid.yaml",
                                   "eks") == resp
Ejemplo n.º 10
0
    def test_load_kind_config_ok(self):
        # Load the K8s configuration for a Kind cluster.
        fname = Filepath("tests/support/kubeconf.yaml")

        ret, err = k8s.load_kind_config(fname, "kind")
        assert not err
        assert ret.url == "https://localhost:8443"
        assert ret.token == ""
        assert ret.version == ""
        assert ret.name == "kind"

        # Function must also accept pathlib.Path instances.
        ret, err = k8s.load_kind_config(pathlib.Path(fname), "kind")
        assert not err
        assert ret.url == "https://localhost:8443"
        assert ret.token == ""
        assert ret.version == ""
        assert ret.name == "kind"

        # Function must have create the credential files.
        assert ret.ca_cert.exists()
        assert ret.client_cert.crt.exists()
        assert ret.client_cert.key.exists()

        # Try to load a GKE context - must fail.
        assert k8s.load_kind_config(fname, "gke") == (K8sConfig(), True)
Ejemplo n.º 11
0
    def test_load_eks_config_err(self, m_run):
        """Load EKS configuration from demo kubeconfig."""
        # Valid kubeconfig file.
        fname = "tests/support/kubeconf.yaml"
        err_resp = (K8sConfig(), True)

        # Pretend the `aws-iam-authenticator` binary does not exist.
        m_run.side_effect = FileNotFoundError
        assert k8s.load_eks_config(fname, "eks") == err_resp

        # Pretend that `aws-iam-authenticator` returned a valid but useless YAML.
        m_run.side_effect = None
        m_run.return_value = types.SimpleNamespace(
            stdout=yaml.dump({}).encode("utf8"))
        assert k8s.load_eks_config(fname, "eks") == err_resp

        # Pretend that `aws-iam-authenticator` returned an invalid YAML.
        m_run.side_effect = None
        invalid_yaml = "invalid :: - yaml".encode("utf8")
        m_run.return_value = types.SimpleNamespace(stdout=invalid_yaml)
        assert k8s.load_eks_config(fname, "eks") == err_resp

        # Pretend that `aws-iam-authenticator` ran without error but
        # returned an empty string. This typically happens if the AWS config
        # files do not exist for the selected AWS profile.
        m_run.side_effect = None
        m_run.return_value = types.SimpleNamespace(stdout=b"")
        assert k8s.load_eks_config(fname, "eks") == err_resp
Ejemplo n.º 12
0
    def test_load_eks_config_ok(self, m_run):
        """Load EKS configuration from demo kubeconfig."""
        # Mock the call to run the `aws-iam-authenticator` tool
        token = yaml.dump({"status": {"token": "EKS token"}})
        m_run.return_value = types.SimpleNamespace(stdout=token.encode("utf8"))

        # Load the K8s configuration for "eks" context.
        fname = "tests/support/kubeconf.yaml"
        ret, err = k8s.load_eks_config(fname, "eks")
        assert not err and isinstance(ret, K8sConfig)

        # The certificate will be in a temporary folder because the `Requests`
        # library insists on reading it from a file. Here we load that file and
        # manually insert its value into the returned Config structure. This
        # will make the verification step below easier to read.
        ca_cert = open(ret.ca_cert, "r").read().strip()
        ret = ret._replace(ca_cert=ca_cert)

        # Verify the expected output.
        assert ret == K8sConfig(
            url="https://5.6.7.8",
            token="EKS token",
            ca_cert="ca.cert",
            client_cert=None,
            version="",
            name="clustername-eks",
        )

        # Verify that the correct external command was called, including
        # environment variables. The "expected_*" values are directly from
        # "support/kubeconf.yaml".
        expected_cmd = [
            "aws-iam-authenticator", "token", "-i", "eks-cluster-name"
        ]
        expected_env = os.environ.copy()
        expected_env.update({"foo1": "bar1", "foo2": "bar2"})
        actual_cmd, actual_env = m_run.call_args[0][0], m_run.call_args[1][
            "env"]
        assert actual_cmd == expected_cmd
        assert actual_env == expected_env

        # EKS is not the default context in the demo kubeconf file, which means
        # this must fail.
        assert k8s.load_eks_config(fname, None) == (K8sConfig(), True)

        # Try to load a Minikube context - must fail.
        assert k8s.load_eks_config(fname, "minikube") == (K8sConfig(), True)
Ejemplo n.º 13
0
    def test_version_auto_err(self, m_get, k8sconfig):
        """Simulate an error when fetching the K8s version."""
        # Create vanilla `K8sConfig` instance.
        k8sconfig = k8sconfig._replace(client=mock.MagicMock())

        # Simulate an error in `get`.
        m_get.return_value = (None, True)

        # Test function must abort gracefully.
        assert k8s.version(k8sconfig) == (K8sConfig(), True)
Ejemplo n.º 14
0
def load_minikube_config(fname: Filepath,
                         context: Optional[str]) -> Tuple[K8sConfig, bool]:
    """Load minikube configuration from `fname`.

    Return None on error.

    Inputs:
        kubconfig: str
            Path to kubeconfig file, eg "~/.kube/config.yaml"
        context: str
            Kubeconf context. Use `None` to use default context.

    Returns:
        Config

    """
    # Parse the kubeconfig file.
    name, user, cluster, err = load_kubeconfig(fname, context)
    if err:
        return (K8sConfig(), True)

    # Minikube uses client certificates to authenticate. We need to pass those
    # to the HTTP client of our choice when we create the session.
    try:
        client_cert = K8sClientCert(
            crt=user["client-certificate"],
            key=user["client-key"],
        )

        # Return the config data.
        logit.info("Assuming Minikube cluster.")
        return K8sConfig(
            url=cluster["server"],
            token="",
            ca_cert=cluster["certificate-authority"],
            client_cert=client_cert,
            version="",
            name=cluster["name"],
        ), False
    except KeyError:
        logit.debug(f"Context {context} in <{fname}> is not a Minikube config")
        return (K8sConfig(), True)
Ejemplo n.º 15
0
def load_incluster_config(
        fname_token: Filepath = FNAME_TOKEN,
        fname_cert: Filepath = FNAME_CERT) -> Tuple[K8sConfig, bool]:
    """Return K8s access config from Pod service account.

    Returns None if we are not running in a Pod.

    Inputs:
        kubconfig: str
            Name of kubeconfig file.
    Returns:
        Config

    """
    # Every K8s pod has this.
    server_ip = os.getenv('KUBERNETES_PORT_443_TCP_ADDR', None)

    fname_cert = pathlib.Path(fname_cert)
    fname_token = pathlib.Path(fname_token)

    # Sanity checks: URL and service account files either exist, or we are not
    # actually inside a Pod.
    try:
        assert server_ip is not None
        assert fname_cert.exists()
        assert fname_token.exists()
    except AssertionError:
        logit.debug("Could not find incluster (service account) credentials.")
        return K8sConfig(), True

    # Return the compiled K8s access configuration.
    logit.info("Use incluster (service account) credentials.")
    return K8sConfig(
        url=f'https://{server_ip}',
        token=fname_token.read_text(),
        ca_cert=fname_cert,
        client_cert=None,
        version="",
        name="",
    ), False
Ejemplo n.º 16
0
def load_auto_config(fname: Filepath,
                     context: Optional[str],
                     disable_warnings: bool = False) -> Tuple[K8sConfig, bool]:
    """Automagically find and load the correct K8s configuration.

    This function will load several possible configuration options and returns
    the first one with a match. The order is as follows:

    1) `load_incluster_config`
    2) `load_gke_config`

    Inputs:
        fname: str
            Path to kubeconfig file, eg "~/.kube/config.yaml"
            Use `None` to find out automatically or for incluster credentials.

        context: str
            Kubeconf context. Use `None` to use default context.

    Returns:
        Config

    """
    conf, err = load_incluster_config()
    if not err:
        return conf, False
    logit.debug("Incluster config failed")

    conf, err = load_minikube_config(fname, context)
    if not err:
        return conf, False
    logit.debug("Minikube config failed")

    conf, err = load_kind_config(fname, context)
    if not err:
        return conf, False
    logit.debug("KIND config failed")

    conf, err = load_eks_config(fname, context, disable_warnings)
    if not err:
        return conf, False
    logit.debug("EKS config failed")

    conf, err = load_gke_config(fname, context, disable_warnings)
    if not err:
        return conf, False
    logit.debug("GKE config failed")

    logit.error(f"Could not find a valid configuration in <{fname}>")
    return (K8sConfig(), True)
Ejemplo n.º 17
0
def cluster_config(kubeconfig: Filepath,
                   context: Optional[str]) -> Tuple[K8sConfig, bool]:
    """Return web session to K8s API.

    This will read the Kubernetes credentials, contact Kubernetes to
    interrogate its version and then return the configuration and web-session.

    Inputs:
        kubeconfig: str
            Path to kubeconfig file.
        context: str
            Kubernetes context to use (can be `None` to use default).

    Returns:
        K8sConfig

    """
    # Read Kubeconfig file and use it to create a `requests` client session.
    # That session will have the proper security certificates and headers so
    # that subsequent calls to K8s need not deal with it anymore.
    kubeconfig = kubeconfig.expanduser()
    try:
        # Parse Kubeconfig file.
        k8sconfig, err = load_auto_config(kubeconfig,
                                          context,
                                          disable_warnings=True)
        assert not err

        # Configure web session.
        k8sconfig = k8sconfig._replace(client=session(k8sconfig))
        assert k8sconfig.client

        # Contact the K8s API to update version field in `k8sconfig`.
        k8sconfig, err = version(k8sconfig)
        assert not err and k8sconfig

        # Populate the `k8sconfig.apis` field.
        err = compile_api_endpoints(k8sconfig)
        assert not err
    except AssertionError:
        return (K8sConfig(), True)

    # Log the K8s API address and version.
    logit.info(f"Kubernetes server at {k8sconfig.url}")
    logit.info(f"Kubernetes version is {k8sconfig.version}")
    return (k8sconfig, False)
Ejemplo n.º 18
0
    def test_cluster_config(self):
        """Basic success/failure test for K8s configuration."""
        # Only show INFO and above or otherwise this test will produce a
        # humongous amount of logs from all the K8s calls.
        square.square.setup_logging(2)

        # Fixtures.
        fun = square.k8s.cluster_config
        fname = Filepath("/tmp/kubeconfig-kind.yaml")

        # Must produce a valid K8s configuration.
        cfg, err = fun(fname, None)
        assert not err and isinstance(cfg, K8sConfig)

        # Gracefully handle connection errors to K8s.
        with mock.patch.object(square.k8s, "session") as m_sess:
            m_sess.return_value = None
            assert fun(fname, None) == (K8sConfig(), True)
Ejemplo n.º 19
0
def k8sconfig() -> Generator[K8sConfig, None, None]:
    # Return a valid K8sConfig with a subsection of API endpoints available in
    # K8s v1.15.
    cfg = K8sConfig(version="1.15", client="k8s_client")

    # The set of API endpoints we can use in the tests.
    cfg.apis.clear()
    cfg.apis.update(k8s_apis(cfg))

    # Manually insert common short spellings.
    cfg.short2kind["deployment"] = "Deployment"
    cfg.short2kind["service"] = "Service"
    cfg.short2kind["svc"] = "Service"
    cfg.short2kind["secret"] = "Secret"
    cfg.short2kind["ns"] = "Namespace"
    cfg.short2kind["namespace"] = "Namespace"

    # The set of canonical K8s resources we support.
    cfg.kinds.update({_ for _ in cfg.short2kind.values()})

    # Pass the fixture to the test.
    yield cfg
Ejemplo n.º 20
0
    def test_load_auto_config(self, m_gke, m_eks, m_kind, m_mini, m_incluster):
        """`load_auto_config` must pick the first successful configuration."""
        fun = k8s.load_auto_config

        m_incluster.return_value = (K8sConfig(), False)
        m_mini.return_value = (K8sConfig(), False)
        m_kind.return_value = (K8sConfig(), False)
        m_eks.return_value = (K8sConfig(), False)
        m_gke.return_value = (K8sConfig(), False)

        # Incluster returns a non-zero value.
        kubeconf, context = "kubeconf", "context"
        assert fun(kubeconf, context) == m_incluster.return_value
        m_incluster.assert_called_once_with()

        # Incluster fails but Minikube does not.
        m_incluster.return_value = (K8sConfig(), True)
        assert fun(kubeconf, context) == m_mini.return_value
        m_mini.assert_called_once_with(kubeconf, context)

        # Incluster & Minikube fail but KIND succeeds.
        m_mini.return_value = (K8sConfig(), True)
        assert fun(kubeconf, context) == m_kind.return_value
        m_kind.assert_called_once_with(kubeconf, context)

        # Incluster & Minikube & KIND fail but EKS succeeds.
        m_kind.return_value = (K8sConfig(), True)
        assert fun(kubeconf, context) == m_eks.return_value
        m_eks.assert_called_once_with(kubeconf, context, False)

        # Incluster & Minikube & KIND & EKS fail but GKE succeeds.
        m_eks.return_value = (K8sConfig(), True)
        assert fun(kubeconf, context) == m_gke.return_value
        m_gke.assert_called_once_with(kubeconf, context, False)

        # All fail.
        m_gke.return_value = (K8sConfig(), True)
        assert fun(kubeconf, context) == (K8sConfig(), True)
Ejemplo n.º 21
0
def load_eks_config(fname: Filepath,
                    context: Optional[str],
                    disable_warnings: bool = False) -> Tuple[K8sConfig, bool]:
    """Return K8s access config for EKS cluster described in `kubeconfig`.

    Returns None if `kubeconfig` does not exist or could not be parsed.

    Inputs:
        fname: Filepath
            Kubeconfig file.
        context: str
            Kubeconf context. Use `None` to use default context.
        disable_warnings: bool
            Whether or not do disable GCloud warnings.

    Returns:
        Config

    """
    # Parse the kubeconfig file.
    name, user, cluster, err = load_kubeconfig(fname, context)
    if err:
        return (K8sConfig(), True)

    # Get a copy of all env vars. We will pass that one along to the
    # sub-process, plus the env vars specified in the kubeconfig file.
    env = os.environ.copy()

    # Unpack the self signed certificate (AWS does not register the K8s API
    # server certificate with a public CA).
    try:
        ssl_ca_cert_data = base64.b64decode(
            cluster["certificate-authority-data"])
        cmd = user["exec"]["command"]
        args = user["exec"]["args"]
        env_kubeconf = user["exec"].get("env", [])
    except KeyError:
        logit.debug(f"Context {context} in <{fname}> is not an EKS config")
        return (K8sConfig(), True)

    # Convert a None value (valid value in YAML) to an empty list of env vars.
    env_kubeconf = env_kubeconf if env_kubeconf else []

    # Save the certificate to a temporary file. This is only necessary because
    # the Requests library will need a path to the CA file - unfortunately, we
    # cannot just pass it the content.
    _, tmp = tempfile.mkstemp(text=False)
    ssl_ca_cert = Filepath(tmp)
    ssl_ca_cert.write_bytes(ssl_ca_cert_data)

    # Compile the name, arguments and env vars for the command specified in kubeconf.
    cmd_args = [cmd] + args
    env_kubeconf = {_["name"]: _["value"] for _ in env_kubeconf}
    env.update(env_kubeconf)
    logit.debug(
        f"Requesting EKS certificate: {cmd_args} with envs: {env_kubeconf}")

    # Pre-format the command for the log message.
    log_cmd = (f"kubeconf={fname} kubectx={context} "
               f"cmd={cmd_args}  env={env_kubeconf}")

    # Run the specified command to produce the access token. That program must
    # produce a YAML document on stdout that specifies the bearer token.
    try:
        out = subprocess.run(cmd_args, stdout=subprocess.PIPE, env=env)
        token = yaml.safe_load(out.stdout.decode("utf8"))["status"]["token"]
    except FileNotFoundError:
        logit.error(
            f"Could not find {cmd} application to get token ({log_cmd})")
        return (K8sConfig(), True)
    except (KeyError, yaml.YAMLError):
        logit.error(
            f"Token manifest produce by {cmd_args} is corrupt ({log_cmd})")
        return (K8sConfig(), True)
    except TypeError:
        logit.error(
            f"The YAML token produced by {cmd_args} is corrupt ({log_cmd})")
        return (K8sConfig(), True)

    # Return the config data.
    logit.info("Assuming EKS cluster.")
    return K8sConfig(
        url=cluster["server"],
        token=token,
        ca_cert=ssl_ca_cert,
        client_cert=None,
        version="",
        name=cluster["name"],
    ), False
Ejemplo n.º 22
0
def compile_api_endpoints(k8sconfig: K8sConfig) -> bool:
    """Populate `k8sconfig.apis` with all the K8s endpoints`.

    NOTE: This will purge the existing content in `k8sconfig.apis`.

    Returns a dictionary like the following:
    {
      ('ConfigMap', 'v1'): K8sResource(
        apiVersion=v1, kind='ConfigMap', name='configmaps', namespaced=True,
        url='https://localhost:8443/api/v1/configmaps'),
      ('CronJob', 'batch/v1beta1): K8sResource(
        apiVersion='batch/v1beta1', kind='CronJob', name='cronjobs', namespaced=True,
        url='https://localhost:8443/apis/batch/v1beta1/cronjobs'),
      ('DaemonSet', 'apps/v1'): K8sResource(
        apiVersion='apps/v1', kind='DaemonSet', name='daemonsets', namespaced=True,
        url='https://localhost:8443/apis/apps/v1/daemonsets',
      ('DaemonSet', apps/v1beta1): K8sResource(
        apiVersion='apps/v1beta1', kind='DaemonSet', name='daemonsets', namespaced=True,
        url='https://localhost:8443/apis/extensions/v1beta1/daemonsets'),
    }

    Inputs:
        k8sconfig: K8sConfig

    """
    # Compile the list of all K8s API groups that this K8s instance knows about.
    resp, err = get(k8sconfig.client, f"{k8sconfig.url}/apis")
    if err:
        logit.error(
            f"Could not interrogate {k8sconfig.name} ({k8sconfig.url}/apis)")
        return True

    # Compile the list of all API groups and their endpoints. Example
    # apigroups = {
    #     'extensions': {('extensions/v1beta1', 'apis/extensions/v1beta1')},
    #     'apps': {('apps/v1', 'apis/apps/v1'),
    #              ('apps/v1beta1', 'apis/apps/v1beta1'),
    #              ('apps/v1beta2', 'apis/apps/v1beta2')},
    #     'batch': {('batch/v1', 'apis/batch/v1'),
    #               ('batch/v1beta1', 'apis/batch/v1beta1')},
    #     ...
    # }
    apigroups: Dict[str, Set[Tuple[str, str]]] = {}
    preferred_group: Dict[str, str] = {}
    for group in resp["groups"]:
        name = group["name"]

        # Store the preferred version, eg ("", "apis/v1").
        apigroups[name] = set()

        # Compile all alternative versions into the same set.
        for version in group["versions"]:
            ver = version["groupVersion"]
            apigroups[name].add((ver, f"apis/{ver}"))
            preferred_group[ver] = group["preferredVersion"]["groupVersion"]
        del group

    # The "v1" group comprises the traditional core components like Service and
    # Pod. This group is a special case and exposed under "api/v1" instead
    # of the usual `apis/...` path.
    apigroups["v1"] = {("v1", "api/v1")}
    preferred_group["v1"] = "v1"

    # Contact K8s to find out which resources each API group offers.
    # This will produce the following group_urls below (K = `K8sResource`): {
    #  ('apps', 'apps/v1', 'apis/apps/v1'): [
    #   K(*, kind='DaemonSet', name='daemonsets', namespaced=True, url='apis/apps/v1'),
    #   K(*, kind='Deployment', name='deployments', namespaced=True, url='apis/apps/v1'),
    #   K(*, kind='ReplicaSet', name='replicasets', namespaced=True, url='apis/apps/v1'),
    #   K(*, kind='StatefulSet', name='statefulsets', namespaced=True, url='apis/apps/v1')
    #  ],
    #  ('apps', 'apps/v1beta1', 'apis/apps/v1beta1')': [
    #   K(..., kind='Deployment', name='deployments', namespaced=True, url=...),
    #   K(..., kind='StatefulSet', name='statefulsets', namespaced=True, url=...)
    #  ],
    # }
    group_urls: Dict[Tuple[str, str, str], List[K8sResource]] = {}
    for group_name, ver_url in apigroups.items():
        for api_version, url in ver_url:
            resp, err = get(k8sconfig.client, f"{k8sconfig.url}/{url}")
            if err:
                msg = f"Could not interrogate {k8sconfig.name} ({k8sconfig.url}/{url})"
                logit.error(msg)
                return True

            data, short2kind = parse_api_group(api_version, url, resp)
            group_urls[(group_name, api_version, url)] = data
            k8sconfig.short2kind.update(short2kind)

    # Produce the entries for `K8sConfig.apis` as described in the doc string.
    k8sconfig.apis.clear()
    default: Dict[str, Set[K8sResource]] = defaultdict(set)
    for (group_name, api_version, url), resources in group_urls.items():
        for res in resources:
            key = (res.kind, res.apiVersion)  # fixme: define namedtuple
            k8sconfig.apis[key] = res._replace(
                url=f"{k8sconfig.url}/{res.url}")

            if preferred_group[api_version] == api_version:
                default[res.kind].add(k8sconfig.apis[key])
                k8sconfig.apis[(res.kind, "")] = k8sconfig.apis[key]

    # Determine the default API endpoint Square should query for each resource.
    for kind, resources in default.items():
        # Happy case: the resource is only available from a single API group.
        if len(resources) == 1:
            k8sconfig.apis[(kind, "")] = resources.pop()
            continue

        # If we get here then it means a resource is available from different
        # API groups. Here we use heuristics to pick one. The heuristic is
        # simply to look for one that is neither alpha nor beta. In Kubernetes
        # v1.15 this resolves almost all disputes.
        all_apis = list(sorted([_.apiVersion for _ in resources]))

        # Remove all alpha/beta resources.
        prod_apis = [_ for _ in all_apis if not ("alpha" in _ or "beta" in _)]

        # Re-add the alpha/beta resources to the candidate set if we have no
        # production ones to choose from.
        apis = prod_apis if len(prod_apis) > 0 else list(all_apis)

        # Pick the one with probably the highest version number.
        apis.sort()
        version = apis.pop()
        k8sconfig.apis[(kind, "")] = [
            _ for _ in resources if _.apiVersion == version
        ][0]

        # Log the available options. Mark the one Square chose with a "*".
        tmp = [_ if _ != version else f"*{_}*" for _ in all_apis]
        logit.info(f"Ambiguous {kind.upper()} endpoints: {tmp}")

    # Compile the set of all resource kinds that this Kubernetes cluster supports.
    for kind, _ in k8sconfig.apis:
        k8sconfig.kinds.add(kind)
    return False