diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..53faae9 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,53 @@ +--- +################################# +################################# +## Super Linter GitHub Actions ## +################################# +################################# +name: Lint Code Base + +############################# +# Start the job on all push # +############################# +on: + push: + branches-ignore: [master, main] + # Remove the line above to run when pushing to master + pull_request: + branches: [master, main] + +############### +# Set the Job # +############### +jobs: + build: + # Name the Job + name: Lint Code Base + # Set the agent to run on + runs-on: ubuntu-latest + + ################## + # Load all steps # + ################## + steps: + ########################## + # Checkout the code base # + ########################## + - name: Checkout Code + uses: actions/checkout@v3 + with: + # Full git history is needed to get a proper + # list of changed files within `super-linter` + fetch-depth: 0 + + ################################ + # Run Linter against code base # + ################################ + - name: Lint Code Base + uses: github/super-linter@v4 + env: + VALIDATE_ALL_CODEBASE: false + KUBERNETES_KUBEVAL_OPTIONS: --ignore-missing-schemas + VALIDATE_PYTHON_MYPY: false + DEFAULT_BRANCH: main + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2ccbce8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +--- +name: Release + +on: + push: + tags: + - "*" + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Build and publish the container image for ${{ github.repository }}:${{ github.ref_name }} + uses: macbre/push-to-ghcr@v12 + with: + image_name: ${{ github.repository }} + github_token: ${{ secrets.GITHUB_TOKEN }} + dockerfile: build/container/Dockerfile + image_tag: ${{ github.ref_name }} + - name: Build and publish the container image for ${{ github.repository }}:latest + uses: macbre/push-to-ghcr@v12 + with: + image_name: ${{ github.repository }} + github_token: ${{ secrets.GITHUB_TOKEN }} + dockerfile: build/container/Dockerfile + image_tag: latest + - name: Create a Release + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + with: + name: X K8Spin internal kubeconfig generator ${{ github.ref_name }} diff --git a/.gitignore b/.gitignore index b6e4761..be90821 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ __pycache__/ # Distribution / packaging .Python -build/ develop-eggs/ dist/ downloads/ @@ -127,3 +126,5 @@ dmypy.json # Pyre type checker .pyre/ + +.vscode diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2a0878d --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +IMAGE=internal-kubeconfig-generator + +lint: + @docker run --rm -e RUN_LOCAL=true -e KUBERNETES_KUBEVAL_OPTIONS=--ignore-missing-schemas -e VALIDATE_PYTHON_MYPY=false -v $(shell pwd):/tmp/lint github/super-linter:v4 + +build-local: + @docker build --no-cache --pull -t $(IMAGE):local . -f build/container/Dockerfile + +clean: + @find . -name "*.pyc" -exec rm -f {} \; + @find . -name "__pycache__" -exec rm -rf {} \; + @rm -rf super-linter.log \ No newline at end of file diff --git a/README.md b/README.md index e269827..a711330 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,105 @@ -# internal-kubeconfig-generator -Create dynamically a kubeconfig inside a secret with a kubeconfig from a serviceaccount +# Internal kubeconfig generator + +This simple controller generates a `kubeconfig` and stores it in a `Secret` for each +[`Secret` created in a specific namespace for a specific service account.](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#manually-create-a-long-lived-api-token-for-a-serviceaccount) + +## Warning + +**The project is currently under development and is not ready for production use.** + +## Motivation + +The main motivation for developing this controller is to enable the multi-tenancy feature of Crossplane. + +The crossplane kubernetes provider does not support the usage of `ServiceAccount`s resource yet to configure +a `providerConfig`. Then, the only way to configure a `providerConfig` is to use a `Secret` with a `kubeconfig`. + +This is where this controller comes in. + +## How it works + +The controller watches for `Secret's` with a few specific annotations: + +- `kubernetes.io/service-account.name`: This is the required annotation to tell the kubernetes controller which `ServiceAccount` belongs to this `Secret`. +- `x.k8spin.cloud/kubeconfig`: This is the trigger annotation to tell the controller to generate a `kubeconfig` for this `Secret`. + +The controller will generate a new `Secret` with the same name as the `ServiceAccount` defined in the `kubernetes.io/service-account.name` annotation ending with `-kubeconfig` suffix. + +## How to use it + +```bash +$ kubectl apply -f https://raw.githubusercontent.com/angelbarrera92/internal-kubeconfig-generator/master/deploy/kubernetes/deploy.yaml +namespace/k8spin-system created +serviceaccount/internal-kubeconfig-generator created +clusterrole.rbac.authorization.k8s.io/internal-kubeconfig-generator created +clusterrolebinding.rbac.authorization.k8s.io/internal-kubeconfig-generator created +deployment.apps/internal-kubeconfig-generator created +$ kubectl wait --for=condition=available --timeout=600s deployment/internal-kubeconfig-generator -n k8spin-system +deployment.apps/internal-kubeconfig-generator condition met +``` + +### Demo + +```bash +$ kubectl apply -f https://raw.githubusercontent.com/angelbarrera92/internal-kubeconfig-generator/master/hack/demo.yaml +clusterrole.rbac.authorization.k8s.io/provider-kubernetes-view created +clusterrolebinding.rbac.authorization.k8s.io/provider-kubernetes-view created +serviceaccount/provider-kubernetes-view created +secret/provider-kubernetes-view created +``` + +This creates a set of resources: +- A `ServiceAccount` named `provider-kubernetes-view` + - [The `Secret` named `provider-kubernetes-view` that contains the `token` for the `ServiceAccount`.](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#manually-create-a-long-lived-api-token-for-a-serviceaccount) +- `ClusterRole` and `ClusterRoleBinding` to allow the `ServiceAccount` to list the `Namespaces`. + +Then, as the `secret` named `provider-kubernetes-view` has the `x.k8spin.cloud/kubeconfig` annotation, the controller will generate a new `Secret` named `provider-kubernetes-view-kubeconfig` with the `kubeconfig` for the `ServiceAccount`. + +```bash +$ kubectl get secret/provider-kubernetes-view-kubeconfig -o yaml +apiVersion: v1 +data: + kubeconfig: +kind: Secret +metadata: + creationTimestamp: "2023-01-04T15:01:39Z" + name: provider-kubernetes-view-kubeconfig + namespace: default + ownerReferences: + - apiVersion: v1 + kind: Secret + name: provider-kubernetes-view + uid: fe338cb5-ac4e-4c7f-9e5a-6b7c68216145 + resourceVersion: "603" + uid: 6c853730-ca5c-4e7d-bfdf-912e31b9c4ec +type: Opaque +``` + +#### Test + +Includes a `Job` that uses the generated `kubeconfig` to list the `Namespaces` in the cluster. + +```bash +$ kubectl apply -f https://raw.githubusercontent.com/angelbarrera92/internal-kubeconfig-generator/master/hack/demo-test.yaml +job.batch/list-namespaces created +$ kubectl logs -f job/list-namespaces +NAME STATUS AGE +default Active 8m17s +kube-system Active 8m17s +kube-public Active 8m17s +kube-node-lease Active 8m16s +k8spin-system Active 7m43s +``` + +## Development + +### Prerequisites + +- [python3](https://www.python.org/downloads/) +- [virtualenv](https://virtualenv.pypa.io/en/latest/installation.html) +- [docker](https://docs.docker.com/install/) + + +## License + +[MIT](LICENSE) diff --git a/build/container/Dockerfile b/build/container/Dockerfile new file mode 100644 index 0000000..b5f94ba --- /dev/null +++ b/build/container/Dockerfile @@ -0,0 +1,16 @@ +# hadolint ignore=DL3007 +FROM cgr.dev/chainguard/python:latest + +# Set the working directory to /home/nonroot +WORKDIR /home/nonroot + +# Setup the virtual environment +RUN ["/usr/bin/python3", "-m" , "venv", "--upgrade-deps", ".venv"] +COPY requirements.txt requirements.txt +RUN [".venv/bin/pip", "install", "--disable-pip-version-check", "-r", "requirements.txt"] + +# Copy the application +COPY main.py main.py + +# Run the application +ENTRYPOINT [".venv/bin/kopf", "run", "-A", "main.py"] diff --git a/deploy/kubernetes/deploy.yaml b/deploy/kubernetes/deploy.yaml new file mode 100644 index 0000000..6702244 --- /dev/null +++ b/deploy/kubernetes/deploy.yaml @@ -0,0 +1,74 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: k8spin-system + labels: + app: k8spin.cloud +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: internal-kubeconfig-generator + namespace: k8spin-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: internal-kubeconfig-generator +rules: +# Allow to get, list and watch namespaces +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "watch"] +# Allow to get, list and watch secrets +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "update", "patch"] +# Allow to get, list and watch service accounts +- apiGroups: [""] + resources: ["serviceaccounts"] + verbs: ["get", "list", "watch"] +# Allow to get, list and watch customresourcedefinitions +- apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list", "watch"] +# Allow to post events +- apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: internal-kubeconfig-generator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: internal-kubeconfig-generator +subjects: +- kind: ServiceAccount + name: internal-kubeconfig-generator + namespace: k8spin-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: k8spin.cloud + name: internal-kubeconfig-generator + namespace: k8spin-system +spec: + selector: + matchLabels: + app: k8spin.cloud + template: + metadata: + labels: + app: k8spin.cloud + spec: + serviceAccountName: internal-kubeconfig-generator + containers: + - name: internal-kubeconfig-generator + image: ghcr.io/angelbarrera92/internal-kubeconfig-generator:latest + imagePullPolicy: Always diff --git a/hack/demo-test.yaml b/hack/demo-test.yaml new file mode 100644 index 0000000..a187b7c --- /dev/null +++ b/hack/demo-test.yaml @@ -0,0 +1,28 @@ +# Create a Kubernetes job that runs a kubectl command to list all namespaces +# Use the kubeconfig that is inside the secret provider-kubernetes-view-kubeconfig in the kubeconfig key +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: list-namespaces + namespace: default +spec: + template: + spec: + containers: + - name: kubectl + image: bitnami/kubectl:1.25 + command: ["kubectl", "get", "ns", "--kubeconfig", "/kubeconfig/config"] + volumeMounts: + - name: kubeconfig + mountPath: /kubeconfig + readOnly: true + volumes: + - name: kubeconfig + secret: + secretName: provider-kubernetes-view-kubeconfig + items: + - key: kubeconfig + path: config + restartPolicy: Never + backoffLimit: 4 diff --git a/hack/demo.yaml b/hack/demo.yaml new file mode 100644 index 0000000..48a4fab --- /dev/null +++ b/hack/demo.yaml @@ -0,0 +1,40 @@ +--- +# Create a clusterrole that allows to list namespaces +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: provider-kubernetes-view +rules: +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "watch"] +--- +# Create a clusterrolebinding that binds the clusterrole to the serviceaccount provider-kubernetes-view +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: provider-kubernetes-view +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: provider-kubernetes-view +subjects: +- kind: ServiceAccount + name: provider-kubernetes-view + namespace: default +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: provider-kubernetes-view + namespace: default +--- +apiVersion: v1 +kind: Secret +metadata: + name: provider-kubernetes-view + namespace: default + annotations: + kubernetes.io/service-account.name: provider-kubernetes-view + x.k8spin.cloud/kubeconfig: "" +type: kubernetes.io/service-account-token diff --git a/main.py b/main.py new file mode 100644 index 0000000..8b6586c --- /dev/null +++ b/main.py @@ -0,0 +1,70 @@ +from base64 import b64decode, b64encode + +import kopf +from kubernetes import client, config + +KUBERNETES_SERVICE = "https://kubernetes.default.svc.cluster.local" + + +@kopf.on.create( + "secrets", + annotations={ + "kubernetes.io/service-account.name": kopf.PRESENT, + "x.k8spin.cloud/kubeconfig": kopf.PRESENT, + }, +) +def secrets(body, logger, **_): + logger.debug(f"Processing secret {body['metadata']['name']}") + # Get the ca.crt from the secret + b64CA = body["data"]["ca.crt"] + # Get the token from the secret + b64Token = body["data"]["token"] + token = b64decode(b64Token).decode("utf-8") + # Get the service account name + name = body["metadata"]["annotations"]["kubernetes.io/service-account.name"] + # Get the namespace + namespace = body["metadata"]["namespace"] + # Create the kubeconfig + kubeconfig = f"""apiVersion: v1 +kind: Config +clusters: +- cluster: + certificate-authority-data: {b64CA} + server: {KUBERNETES_SERVICE} + name: in-cluster +contexts: +- context: + cluster: in-cluster + user: {name} + name: in-cluster +current-context: in-cluster +users: +- name: {name} + user: + token: {token} +preferences: {{}} +""" + logger.info(f"Created kubeconfig for {name}") + # Create the secret object + kubeconfigSecret = client.V1Secret() + kubeconfigSecret.metadata = client.V1ObjectMeta(name=f"{name}-kubeconfig") + kubeconfigSecret.type = "Opaque" + kubeconfigSecret.data = { + "kubeconfig": b64encode(kubeconfig.encode("utf-8")).decode("utf-8") + } + kubeconfigSecret.metadata.owner_references = [ + client.V1OwnerReference( + api_version="v1", + kind="Secret", + name=body["metadata"]["name"], + uid=body["metadata"]["uid"], + ) + ] + + # Create the secret in the cluster + logger.debug(f"Creating kubeconfig secret for {name}") + config.load_incluster_config() + kubeconfigSecret = client.CoreV1Api().create_namespaced_secret( + namespace, kubeconfigSecret + ) + logger.info(f"Created kubeconfig secret {kubeconfigSecret.metadata.name}") diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9db4397 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +black \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d1f5d64 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +kopf +kubernetes