Repository: glasskube/glasskube
Pull request #358 (2024-03-12 12:43:55)

chore(deps): update actions/checkout digest to b4ffde6
[![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/checkout](https://togithub.com/actions/checkout) | action | digest | `9bb5618` -> `b4ffde6` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://developer.mend.io/github/glasskube/glasskube).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy4yMzguMSIsInVwZGF0ZWRJblZlciI6IjM3LjIzOC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiJ9-->



Patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 6fdc0ac..9c71f4b 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -14,7 +14,7 @@ jobs:
     name: make all
     steps:
       - name: Checkout
-        uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4
+        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
       - name: Setup Go
         uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5
         with:
diff --git a/.github/workflows/commit-lint.yaml b/.github/workflows/commit-lint.yaml
index 275bbd9..6c1fb8c 100644
--- a/.github/workflows/commit-lint.yaml
+++ b/.github/workflows/commit-lint.yaml
@@ -7,7 +7,7 @@ jobs:
   commit-lint:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4
+      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
         with:
           fetch-depth: 0
       - name: Set up node
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index dc490df..7c16935 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -22,7 +22,7 @@ jobs:
       tag_name: ${{ steps.release-please.outputs.tag_name }}
     steps:
       - name: Checkout
-        uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4
+        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
       - name: Release Please
         id: release-please
         uses: google-github-actions/release-please-action@a37ac6e4f6449ce8b3f7607e4d97d0146028dc0b # v4.1.0
@@ -38,11 +38,11 @@ jobs:
     if: ${{ needs.release-please.outputs.releases_created == 'true' || startsWith(github.ref, 'refs/tags/') }}
     steps:
       - name: Checkout
-        uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4
+        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
         with:
           fetch-depth: 0
       - name: Login to GitHub Container Registry
-        uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3
+        uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3
         with:
           registry: ghcr.io
           username: glasskube-bot
diff --git a/.github/workflows/schema.yaml b/.github/workflows/schema.yaml
index ba9c53a..dada330 100644
--- a/.github/workflows/schema.yaml
+++ b/.github/workflows/schema.yaml
@@ -16,7 +16,7 @@ jobs:
   validate:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4
+      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
       - name: Validate Package Manifest Schema
         uses: cardinalby/schema-validator-action@v3
         with:
diff --git a/.github/workflows/website.yaml b/.github/workflows/website.yaml
index 4240d10..7474111 100644
--- a/.github/workflows/website.yaml
+++ b/.github/workflows/website.yaml
@@ -18,7 +18,7 @@ jobs:
       run:
         working-directory: website
     steps:
-      - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4
+      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
       - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
         with:
           node-version: '20.9'
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 91c82ce..a6e1527 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -33,6 +33,13 @@ builds:
       - linux
     main: ./cmd/package-operator
     binary: package-operator
+  - id: cert-manager
+    env:
+      - CGO_ENABLED=0
+    goos:
+      - linux
+    main: ./cmd/cert-manager
+    binary: cert-manager
 
 dockers:
   - image_templates:
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 28e831b..2ed3b71 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
 {
-  ".": "0.0.3"
+  ".": "0.0.4"
 }
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0dd481d..d81868d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,49 @@
 # Changelog
 
+## [0.0.4](https://github.com/glasskube/glasskube/compare/v0.0.3...v0.0.4) (2024-03-12)
+
+
+### Features
+
+* **cli:** add --no-await in install command ([51bc1be](https://github.com/glasskube/glasskube/commit/51bc1be5444deb722fa96d26804e3cf8b2560aec))
+* **cli:** add --no-await in uninstall command ([a166b36](https://github.com/glasskube/glasskube/commit/a166b36460ff7ce6c903f244c8baa1c69af50f12))
+* **client:** parallelize IsBootstrap function ([8d8559e](https://github.com/glasskube/glasskube/commit/8d8559e0fc938ff54ea348efc02095579dc3d24b))
+* **package-controller:** install package dependencies ([#111](https://github.com/glasskube/glasskube/issues/111)) ([3c83668](https://github.com/glasskube/glasskube/commit/3c83668ea65d240b678864e522d98a72f87d4cee))
+* **package-controller:** support versions for dependent packages ([#311](https://github.com/glasskube/glasskube/issues/311)) ([e22a401](https://github.com/glasskube/glasskube/commit/e22a4019405e2beb35595484b9207d0ad7b00350))
+* **package-operator:** add default namespace handling for manifests ([97e17a5](https://github.com/glasskube/glasskube/commit/97e17a5ff8bbb57f73fbcd9d39d1700797b0be6d))
+* **package-operator:** add handling of packages with helm and manifests ([149a8c7](https://github.com/glasskube/glasskube/commit/149a8c7e5d7deb7f192cb76e189e4f2b42a6bf3c))
+* **ui:** add dark mode ([60ad43e](https://github.com/glasskube/glasskube/commit/60ad43ebda9cb9d34fdebb80635ec2667d12db15))
+
+
+### Bug Fixes
+
+* change opener to choose first ready pod ([b018081](https://github.com/glasskube/glasskube/commit/b018081693ce689c26c2fe11a7dd1de4293d95a9))
+* **deps:** update module github.com/onsi/ginkgo/v2 to v2.16.0 ([b268277](https://github.com/glasskube/glasskube/commit/b26827731447b2f7e04152057b1fede9bd753f5d))
+* **ui, cli:** add fallback to manifest from repo ([0e5d024](https://github.com/glasskube/glasskube/commit/0e5d0243244be949db6ac502db4eb33f3a835332))
+* **ui:** hide global update notification ([#355](https://github.com/glasskube/glasskube/issues/355)) ([d2e60ca](https://github.com/glasskube/glasskube/commit/d2e60cadc657942ce23ac23da7210381faae2266))
+
+
+### Other
+
+* add commitlint to root project ([54fd3a9](https://github.com/glasskube/glasskube/commit/54fd3a90348f7b38dd3da8ea2a33b048d5517f6c))
+* add prettier for web formatting ([1493268](https://github.com/glasskube/glasskube/commit/1493268e46bb72c81dba19d430e1d0ccf501045f))
+* **deps:** update actions/checkout digest to 9bb5618 ([12303a2](https://github.com/glasskube/glasskube/commit/12303a22b2f1e526a2379295958936295d8f7f94))
+* **deps:** update azure/setup-kubectl action to v4 ([cbb4712](https://github.com/glasskube/glasskube/commit/cbb4712254115dbfba3f12fa91ce7b85b54262eb))
+* **deps:** update commitlint monorepo to v19.1.0 ([170e905](https://github.com/glasskube/glasskube/commit/170e905410d6e464355b09e9b65ff5e7e2511cc6))
+* **deps:** update dependency typescript to ~5.4.0 ([004dcbe](https://github.com/glasskube/glasskube/commit/004dcbeb3cf776c57badff153eff3f00ca44d5af))
+* **deps:** update google-github-actions/release-please-action action to v4.1.0 ([30871fa](https://github.com/glasskube/glasskube/commit/30871faf683ce392c78e89b955520fab971ad3fe))
+
+
+### Docs
+
+* remove disclaimer from package update section ([7cf43eb](https://github.com/glasskube/glasskube/commit/7cf43ebde2ec47316ce2a111572488079f15d368))
+* **website:** adapt roadmap to match our new release convention ([ff41302](https://github.com/glasskube/glasskube/commit/ff413026b39b57e1b95bf4fbcad787aded2ca739))
+* **website:** add latest release to roadmap ([319d33e](https://github.com/glasskube/glasskube/commit/319d33e9aa4a74b113e959f52f15a709a82c6ba1))
+* **website:** add latest release video ([b58eb3b](https://github.com/glasskube/glasskube/commit/b58eb3bd651bb6eb60dcdcfec309a4a5caa98229))
+* **website:** add shadow to screenshot ([d1b1b9d](https://github.com/glasskube/glasskube/commit/d1b1b9d1b51b0e4c27269c6930b16960436ad701))
+* **website:** add v0.0.3 release blogpost ([e080966](https://github.com/glasskube/glasskube/commit/e08096676f1e49ede03a07711d00bd3e7d2c4dda))
+* **website:** fix release video url ([3b8a07b](https://github.com/glasskube/glasskube/commit/3b8a07b245a4918cd916a5902aa72c1e4d3f91ae))
+
 ## [0.0.3](https://github.com/glasskube/glasskube/compare/v0.0.2...v0.0.3) (2024-02-27)
 
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2622925..680e596 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,20 +6,24 @@ The goal of this document is to define some guidelines to streamline our contrib
 ## Getting started
 
 ### Where should I start?
+
 - If you are new to the project, please check out the [good first issue](https://github.com/glasskube/glasskube/labels/good%20first%20issue) label.
 - If you are looking for something to work on, check out our [open issues](hhttps://github.com/glasskube/glasskube/issues).
 - If you have an idea for a new feature, please open an issue, and we can discuss it.
 - We are also happy to help you find something to work on. Just reach out to us.
 
 ### Getting in touch with the community
+
 - Join our [Glasskube Discord Channel](https://discord.gg/SxH6KUCGH7)
 - Introduce yourself on the intros channel or open an issue to let us know that you are interested in contributing
 
 ### Discuss issues
+
 - Before you start working on something, propose and discuss your solution on the issue
 - If you are unsure about something, ask the community
 
 ### How do I contribute?
+
 - Fork the repository and clone it locally
 - Create a new branch and follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) guidelines for work undertaken
 - Assign yourself to the issue, if you are working on it (if you are not a member of the organization, please leave a comment on the issue)
@@ -29,6 +33,7 @@ The goal of this document is to define some guidelines to streamline our contrib
 - Wait for a review and address any comments
 
 ### Opening PRs
+
 - As long as you are working on your PR, please mark it as a draft
 - Please make sure that your PR is up-to-date with the latest changes in `main`
 - Fill out the PR template
@@ -36,6 +41,7 @@ The goal of this document is to define some guidelines to streamline our contrib
 - Make sure that your PR passes all checks
 
 ### Reviewing PRs
+
 - Be respectful and constructive
 - Assign yourself to the PR
 - Check if all checks are passing
@@ -58,14 +64,14 @@ We require all commits in this repository to adhere to the following commit mess
 
 The following `<type>`s are available:
 
-* `fix` (bug fix)
-* `feat` (includes new feature)
-* `docs` (update to our documentation)
-* `build` (update to the build config)
-* `perf` (performance improvement)
-* `style` (code style change without any other changes)
-* `refactor` (code refactoring)
-* `chore` (misc. routine tasks; e.g. dependency updates)
+- `fix` (bug fix)
+- `feat` (includes new feature)
+- `docs` (update to our documentation)
+- `build` (update to the build config)
+- `perf` (performance improvement)
+- `style` (code style change without any other changes)
+- `refactor` (code refactoring)
+- `chore` (misc. routine tasks; e.g. dependency updates)
 
 This format is based on [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
 Please refer to the Conventional Commits specification for more details.
@@ -92,6 +98,8 @@ After you make more changes, simply run `make` again to recompile your changes.
 
 ### Executing
 
+#### glasskube
+
 In order to execute the `glasskube` binary locally, you can do this manually by creating a copy of it to your project directory.
 
 However, there's an easy and preferred way for doing this by creating an `alias` using the following command:
@@ -104,6 +112,35 @@ This will make sure the `alias-name` is in sync with your glasskube binary. Howe
 
 **Note:** Don't use `alias-name` as _glasskube_ since the actual glasskube CLI tool installed locally will get in conflict with executable `glasskube` binary.
 
+#### package-operator
+
+**tl;dr:** Use [minikube](https://minikube.sigs.k8s.io/docs/) during development and run: `make install webhook cert run`
+
+For development, we provide the following `make` targets:
+
+- `make install` installs the package-operator CRDs in the current cluster.
+- `make webhook` installs the package-operator webhook CRDs in the current cluster, including a patch to allow using the package-operator running on the local machine for the validating admission webhook (only works on minikube).
+- `make cert` runs the package-operator cert-manager locally to generate a self signed TLS certificate and patch the ValidatingWebhookConfiguration with the CA bundle. The TLS certificate is valid for 1 year, but is saved in a temporary directory, so it is recommended to run this task at least once everytime the machine is restarted.
+- `make run` runs the package-operator locally.
+- `make docker-build` builds a docker image for the package-operator.
+- `make deploy` applies the full package-operator manifest (excluding dependencies) in the current cluster.
+
+The package-operator ships with a ValidatingAdmissionWebhook. While it is not mandatory to use it during development, we do recommend that you do. Just follow these steps:
+
+1. `make install` creates the package-operator CRDs in your cluster.
+2. `make webhook` creates an "ExternalName" service in your cluster that points to your host machine. This only works if you use [minikube](https://minikube.sigs.k8s.io/docs/), if you want to use [kind](https://kind.sigs.k8s.io/) instead, take a look at [this issue](https://github.com/kubernetes-sigs/kind/issues/1200).
+3. With the webhook configuration in place, you have to generate a TLS certificate locally and patch the webhook configuration with the CA bundle by running `make cert`.
+4. Run the operator using your preferred environment, or `make run`.
+
+When changing the manifests, it is recommended to deploy the package-operator in a minikube cluster. To achieve this, you will have to do three things:
+
+1. Point your local docker CLI to the minikube docker daemon:
+   `minikube docker-env` for more info
+2. Build the docker image:
+   `make docker-build`
+3. Deploy the operator using the locally built image:
+   `make deploy`
+
 ### Testing
 
 Unit tests for the project can be executed by running:
diff --git a/Dockerfile b/Dockerfile
index a227107..0f01718 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,7 +12,8 @@ COPY go.sum go.sum
 RUN go mod download
 
 # Copy the go source
-COPY cmd/package-operator/main.go cmd/main.go
+COPY cmd/package-operator/ cmd/package-operator/
+COPY cmd/cert-manager/ cmd/cert-manager/
 COPY api/ api/
 COPY internal/ internal/
 COPY pkg/ pkg/
@@ -22,13 +23,15 @@ COPY pkg/ pkg/
 # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO
 # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore,
 # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform.
-RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go
+RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o package-operator ./cmd/package-operator
+RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o cert-manager ./cmd/cert-manager
 
 # Use distroless as minimal base image to package the manager binary
 # Refer to https://github.com/GoogleContainerTools/distroless for more details
 FROM gcr.io/distroless/static:nonroot
 WORKDIR /
-COPY --from=builder /workspace/manager .
+COPY --from=builder /workspace/package-operator .
+COPY --from=builder /workspace/cert-manager .
 USER 65532:65532
 
-ENTRYPOINT ["/manager"]
+ENTRYPOINT ["/package-operator"]
diff --git a/Makefile b/Makefile
index af05f3b..aaec84c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
 
 # Image URL to use all building/pushing image targets
-IMG ?= glasskube/package-operator:latest
+IMG ?= ghcr.io/glasskube/package-operator:snapshot
 # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary.
 ENVTEST_K8S_VERSION = 1.28.0
 
@@ -25,7 +25,7 @@ SHELL = /usr/bin/env bash -o pipefail
 .SHELLFLAGS = -ec
 
 .PHONY: all
-all: tidy fmt vet lint schema-gen build build-cli test
+all: tidy fmt vet lint schema-gen build build-cert build-cli test
 
 ##@ General
 
@@ -102,6 +102,10 @@ lint-go-fix: golangci-lint ## Run golangci-lint linter and perform fixes
 build: manifests generate fmt vet lint-go ## Build manager binary.
 	go build -o $(OUT_DIR)/package-operator ./cmd/package-operator/
 
+.PHONY: build-cert
+build-cert: manifests generate fmt vet lint-go ## Build manager binary.
+	go build -o $(OUT_DIR)/cert-manager ./cmd/cert-manager/
+
 .PHONY: build-cli
 build-cli: fmt vet lint-go lint-web web ## Build cli binary.
 	go build -o $(OUT_DIR)/glasskube ./cmd/glasskube/
@@ -110,6 +114,10 @@ build-cli: fmt vet lint-go lint-web web ## Build cli binary.
 run: manifests generate fmt vet ## Run a controller from your host.
 	go run ./cmd/package-operator/
 
+.PHONY: cert
+cert: manifests generate fmt vet
+	go run ./cmd/cert-manager/ --cert-dir /tmp/k8s-webhook-server/serving-certs
+
 # If you wish to build the manager image targeting other platforms you can use the --platform flag.
 # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it.
 # More info: https://docs.docker.com/develop/develop-images/build_enhancements/
@@ -152,9 +160,17 @@ install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~
 uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
 	$(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f -
 
+.PHONY: webhook
+webhook: manifests kustomize
+	$(KUSTOMIZE) build config/webhook-local | $(KUBECTL) apply -f -
+
+.PHONY: uninstall-webhook
+uninstall-webhook: manifests kustomize
+	$(KUSTOMIZE) build config/webhook-local | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f -
+
 .PHONY: deploy
 deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config.
-	cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG}
+	cd config/default && $(KUSTOMIZE) edit set image controller=${IMG}
 	$(KUSTOMIZE) build config/default | $(KUBECTL) apply -f -
 
 .PHONY: undeploy
diff --git a/PROJECT b/PROJECT
index 8ccfb8d..9220d9b 100644
--- a/PROJECT
+++ b/PROJECT
@@ -17,6 +17,9 @@ resources:
   kind: Package
   path: github.com/glasskube/glasskube/api/v1alpha1
   version: v1alpha1
+  webhooks:
+    validation: true
+    webhookVersion: v1
 - api:
     crdVersion: v1
   controller: true
diff --git a/api/v1alpha1/package_types.go b/api/v1alpha1/package_types.go
index c10d44b..3e2272c 100644
--- a/api/v1alpha1/package_types.go
+++ b/api/v1alpha1/package_types.go
@@ -27,8 +27,8 @@ type PackageInfoTemplate struct {
 	// Name of the package to install
 	// +kubebuilder:validation:Required
 	Name string `json:"name"`
-	// Version of the package to install (optional)
-	Version string `json:"version,omitempty"`
+	// Version of the package to install
+	Version string `json:"version"`
 	// Optional URL of the repository to pull the package from
 	RepositoryUrl string `json:"repositoryUrl,omitempty"`
 }
@@ -45,6 +45,7 @@ type PackageStatus struct {
 	Conditions        []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"`
 	OwnedResources    []OwnedResourceRef `json:"ownedResources,omitempty"`
 	OwnedPackageInfos []OwnedResourceRef `json:"ownedPackageInfos,omitempty"`
+	OwnedPackages     []OwnedResourceRef `json:"ownedPackages,omitempty"`
 }
 
 //+kubebuilder:object:root=true
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index d8f8e33..99b18dd 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -401,6 +401,11 @@ func (in *PackageStatus) DeepCopyInto(out *PackageStatus) {
 		*out = make([]OwnedResourceRef, len(*in))
 		copy(*out, *in)
 	}
+	if in.OwnedPackages != nil {
+		in, out := &in.OwnedPackages, &out.OwnedPackages
+		*out = make([]OwnedResourceRef, len(*in))
+		copy(*out, *in)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageStatus.
diff --git a/cmd/cert-manager/main.go b/cmd/cert-manager/main.go
new file mode 100644
index 0000000..eea56a1
--- /dev/null
+++ b/cmd/cert-manager/main.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+	"context"
+	"os"
+
+	"github.com/glasskube/glasskube/internal/certificates"
+	"github.com/go-logr/logr"
+	"github.com/spf13/cobra"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	arv1ac "k8s.io/client-go/applyconfigurations/admissionregistration/v1"
+	corev1ac "k8s.io/client-go/applyconfigurations/core/v1"
+	"k8s.io/client-go/kubernetes"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/log/zap"
+)
+
+var (
+	serviceName, secretName, webhookConfigName, webhookName, namespace, certDir string
+
+	log logr.Logger
+
+	fieldManager = "package-operator-cert-manager"
+	applyOptions = metav1.ApplyOptions{FieldManager: fieldManager, Force: true}
+
+	cmd = &cobra.Command{
+		Use: "cert-manager",
+		Run: func(cmd *cobra.Command, args []string) { run(cmd.Context()) },
+	}
+)
+
+func init() {
+	ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
+	log = ctrl.Log.WithName("cert-manager")
+
+	cmd.Flags().StringVar(&certDir, "cert-dir", "",
+		"directory for certificates (optional)")
+	cmd.Flags().StringVar(&serviceName, "service-name", "glasskube-webhook-service",
+		"name of the webhook service")
+	cmd.Flags().StringVar(&secretName, "secret-name", "glasskube-webhook-tls",
+		"name of the webhook TLS secret")
+	cmd.Flags().StringVar(&webhookConfigName, "webhook-config-name", "glasskube-validating-webhook-configuration",
+		"name of the ValidatingWebhookConfiguration to patch")
+	cmd.Flags().StringVar(&webhookName, "webhook-name", "vpackage.kb.io",
+		"name of the webhook to patch")
+	cmd.Flags().StringVar(&namespace, "namespace", "glasskube-system",
+		"namespace of the webhook service and TLS secret")
+}
+
+func main() {
+	if err := cmd.Execute(); err != nil {
+		log.Error(err, "command execution failed")
+		os.Exit(1)
+	}
+}
+
+func run(ctx context.Context) {
+	client, err := kubernetes.NewForConfig(ctrl.GetConfigOrDie())
+	if err != nil {
+		log.Error(err, "could not initialize kubernetes client")
+		os.Exit(1)
+	}
+
+	certificates, err := certificates.Generate(serviceName, namespace, certificates.DefaultValidity)
+	if err != nil {
+		log.Error(err, "could not generate certificates")
+		os.Exit(1)
+	}
+
+	webhookEnc, err := certificates.Webhook.Encoded()
+	if err != nil {
+		log.Error(err, "could not encode certificates")
+		os.Exit(1)
+	}
+
+	if len(certDir) > 0 {
+		if err := webhookEnc.SaveTo(certDir); err != nil {
+			log.Error(err, "could not save certificates")
+			os.Exit(1)
+		}
+		log.Info("cerificates saved", "dir", certDir)
+	} else {
+		secret := corev1ac.Secret(secretName, namespace).
+			WithData(webhookEnc.AsMap())
+		if _, err := client.CoreV1().Secrets(namespace).Apply(ctx, secret, applyOptions); err != nil {
+			log.Error(err, "could not encode certificates", "name", secretName)
+			os.Exit(1)
+		}
+		log.Info("Secret applied", "name", secretName)
+	}
+
+	caEnc, err := certificates.Ca.Encoded()
+	if err != nil {
+		log.Error(err, "could not encode certificates")
+		os.Exit(1)
+	}
+
+	webhookConfig := arv1ac.ValidatingWebhookConfiguration(webhookConfigName).WithWebhooks(
+		arv1ac.ValidatingWebhook().WithName(webhookName).
+			WithClientConfig(arv1ac.WebhookClientConfig().WithCABundle(caEnc.Cert...)),
+	)
+
+	if _, err := client.AdmissionregistrationV1().ValidatingWebhookConfigurations().
+		Apply(ctx, webhookConfig, applyOptions); err != nil {
+		log.Error(err, "could not apply ValidatingWebhookConfiguration", "name", webhookConfigName)
+		os.Exit(1)
+	}
+
+	log.Info("ValidatingWebhookConfiguration applied", "name", webhookConfigName)
+}
diff --git a/cmd/glasskube/cmd/bootstrap.go b/cmd/glasskube/cmd/bootstrap.go
index 139f8fb..fdba13d 100644
--- a/cmd/glasskube/cmd/bootstrap.go
+++ b/cmd/glasskube/cmd/bootstrap.go
@@ -13,6 +13,7 @@ import (
 type bootstrapOptions struct {
 	url           string
 	bootstrapType bootstrap.BootstrapType
+	latest        bool
 }
 
 var bootstrapCmdOptions = bootstrapOptions{
@@ -28,6 +29,18 @@ var bootstrapCmd = &cobra.Command{
 	PreRun: cliutils.SetupClientContext(false),
 	Run: func(cmd *cobra.Command, args []string) {
 		cfg, _ := cliutils.RequireConfig(config.Kubeconfig)
+
+		if bootstrapCmdOptions.latest {
+			if releaseInfo, err := cliutils.FetchLatestRelease(); err != nil {
+				fmt.Fprintln(os.Stderr, "No updates available, bootstrapping with currently installed version.")
+			} else {
+				bootstrapCmdOptions.url = fmt.Sprintf(
+					"https://github.com/glasskube/glasskube/releases/download/v%s/manifest-aio.yaml",
+					releaseInfo.Version,
+				)
+			}
+		}
+
 		client := bootstrap.NewBootstrapClient(
 			cfg,
 			bootstrapCmdOptions.url,
@@ -45,5 +58,8 @@ func init() {
 	RootCmd.AddCommand(bootstrapCmd)
 	bootstrapCmd.Flags().StringVarP(&bootstrapCmdOptions.url, "url", "u", "", "URL to fetch the Glasskube operator from")
 	bootstrapCmd.Flags().VarP(&bootstrapCmdOptions.bootstrapType, "type", "t", `Type of manifest to use for bootstrapping`)
+	bootstrapCmd.Flags().BoolVar(&bootstrapCmdOptions.latest, "latest", config.Version == "dev",
+		"Fetch and bootstrap the latest version")
 	bootstrapCmd.MarkFlagsMutuallyExclusive("url", "type")
+	bootstrapCmd.MarkFlagsMutuallyExclusive("url", "latest")
 }
diff --git a/cmd/glasskube/cmd/describe.go b/cmd/glasskube/cmd/describe.go
index 663a818..1fb28b7 100644
--- a/cmd/glasskube/cmd/describe.go
+++ b/cmd/glasskube/cmd/describe.go
@@ -23,7 +23,7 @@ var describeCmd = &cobra.Command{
 	ValidArgsFunction: completeAvailablePackageNames,
 	Run: func(cmd *cobra.Command, args []string) {
 		pkgName := args[0]
-		pkg, pkgStatus, manifest, err := describe.DescribePackage(cmd.Context(), pkgName)
+		pkg, pkgStatus, manifest, latestVersion, err := describe.DescribePackage(cmd.Context(), pkgName)
 		if err != nil {
 			fmt.Fprintf(os.Stderr, "❌ Could not describe package %v: %v\n", pkgName, err)
 			os.Exit(1)
@@ -33,7 +33,7 @@ var describeCmd = &cobra.Command{
 		fmt.Printf("%v %v\n", bold("Short Description:"), stringOrDash(manifest.ShortDescription))
 		fmt.Printf("%v \n%v\n", bold("Long Description:"), stringOrDash(manifest.LongDescription))
 		fmt.Printf("%v \n", bold("References:"))
-		version := ""
+		version := latestVersion
 		if pkg != nil {
 			version = pkg.Spec.PackageInfo.Version
 		}
@@ -45,7 +45,35 @@ var describeCmd = &cobra.Command{
 		for _, ref := range manifest.References {
 			fmt.Printf(" * %v: %v\n", ref.Label, ref.Url)
 		}
+
+		fmt.Printf("\n%v\n", bold("Entrypoints:"))
+		if len(manifest.Entrypoints) == 0 {
+			fmt.Fprintln(os.Stderr, " * No Entry Points")
+		} else {
+			for _, i := range manifest.Entrypoints {
+				var messageParts []string
+				if i.Name != "" {
+					messageParts = append(messageParts, fmt.Sprintf("Name: %s", i.Name))
+				}
+				if i.ServiceName != "" {
+					messageParts = append(messageParts, fmt.Sprintf("ServiceName: %s", i.ServiceName))
+				}
+				if i.Port != 0 {
+					messageParts = append(messageParts, fmt.Sprintf("Port: %v", i.Port))
+				}
+				if i.LocalPort != 0 {
+					messageParts = append(messageParts, fmt.Sprintf("LocalPort: %v", i.LocalPort))
+				}
+				if i.Scheme != "" {
+					messageParts = append(messageParts, fmt.Sprintf("Scheme: %s", i.Scheme))
+				}
+				entrypointMsg := strings.Join(messageParts, ", ")
+				fmt.Fprintf(os.Stderr, " * %s\n", entrypointMsg)
+			}
+		}
+
 		fmt.Printf("\n%v %v\n", bold("Status:"), status(pkgStatus))
+
 	},
 }
 
diff --git a/cmd/glasskube/cmd/install.go b/cmd/glasskube/cmd/install.go
index 8a84b59..6cd34d6 100644
--- a/cmd/glasskube/cmd/install.go
+++ b/cmd/glasskube/cmd/install.go
@@ -36,37 +36,35 @@ var installCmd = &cobra.Command{
 		// Instantiate installer
 		installer := install.NewInstaller(client).WithStatusWriter(statuswriter.Spinner())
 
-		if installCmdOptions.Version == "" && !installCmdOptions.EnableAutoUpdates {
-			fmt.Fprintf(os.Stderr, "Version not specified. The latest version of %v will be installed.\n", packageName)
-
-			if !cliutils.YesNoPrompt("Would you like to enable automatic updates?", true) {
-				var packageIndex repo.PackageIndex
-				if err := repo.FetchPackageIndex("", packageName, &packageIndex); err != nil {
-					fmt.Fprintf(os.Stderr, "❗ Error: Could not fetch package metadata: %v\n", err)
-					if !cliutils.YesNoPrompt("Continue anyways? (Automatic updates will be enabled)", false) {
-						cancel()
-					}
-				} else {
-					installCmdOptions.Version = packageIndex.LatestVersion
+		if installCmdOptions.Version == "" {
+			var packageIndex repo.PackageIndex
+			if err := repo.FetchPackageIndex("", packageName, &packageIndex); err != nil {
+				fmt.Fprintf(os.Stderr, "❗ Error: Could not fetch package metadata: %v\n", err)
+				os.Exit(1)
+			}
+			installCmdOptions.Version = packageIndex.LatestVersion
+			fmt.Fprintf(os.Stderr, "Version not specified. The latest version %v of %v will be installed.\n",
+				installCmdOptions.Version, packageName)
+			if !installCmdOptions.EnableAutoUpdates {
+				if cliutils.YesNoPrompt("Would you like to enable automatic updates?", false) {
+					installCmdOptions.EnableAutoUpdates = true
 				}
 			}
 		}
 
-		var msg string
-		if installCmdOptions.Version != "" {
-			msg = fmt.Sprintf("%v (version %v) will be installed in your current cluster (%v).",
-				packageName, installCmdOptions.Version, config.CurrentContext)
-		} else {
-			msg = fmt.Sprintf("%v will be installed in your current cluster (%v).",
-				packageName, config.CurrentContext)
+		autoUpdatesMsg := ""
+		if installCmdOptions.EnableAutoUpdates {
+			autoUpdatesMsg = "The package will be updated automatically. "
 		}
-
-		if !cliutils.YesNoPrompt(fmt.Sprintf("%v Continue?", msg), true) {
+		msg := fmt.Sprintf("%v (version %v) will be installed in your current cluster (%v).\n%v",
+			packageName, installCmdOptions.Version, config.CurrentContext, autoUpdatesMsg)
+		if !cliutils.YesNoPrompt(fmt.Sprintf("%vContinue?", msg), true) {
 			cancel()
 		}
 
 		if installCmdOptions.NoWait {
-			if err := installer.Install(ctx, packageName, installCmdOptions.Version); err != nil {
+			if err := installer.Install(
+				ctx, packageName, installCmdOptions.Version, installCmdOptions.EnableAutoUpdates); err != nil {
 				fmt.Fprintf(os.Stderr, "An error occurred during installation:\n\n%v\n", err)
 				os.Exit(1)
 			}
@@ -75,7 +73,8 @@ var installCmd = &cobra.Command{
 					"💡 Run \"glasskube describe %v\" to get the current status",
 				packageName, packageName)
 		} else {
-			status, err := installer.InstallBlocking(ctx, packageName, installCmdOptions.Version)
+			status, err := installer.InstallBlocking(ctx, packageName, installCmdOptions.Version,
+				installCmdOptions.EnableAutoUpdates)
 			if err != nil {
 				fmt.Fprintf(os.Stderr, "An error occurred during installation:\n\n%v\n", err)
 				os.Exit(1)
diff --git a/cmd/glasskube/cmd/list.go b/cmd/glasskube/cmd/list.go
index af7b7af..020addb 100644
--- a/cmd/glasskube/cmd/list.go
+++ b/cmd/glasskube/cmd/list.go
@@ -124,7 +124,7 @@ func versionString(pkg list.PackageWithStatus) string {
 
 		if statusVersion != "" {
 			versionAddons := []string{}
-			if specVersion != "" && statusVersion != specVersion {
+			if statusVersion != specVersion {
 				versionAddons = append(versionAddons, fmt.Sprintf("%v desired", specVersion))
 			}
 			if repoVersion != "" && statusVersion != repoVersion {
@@ -135,14 +135,11 @@ func versionString(pkg list.PackageWithStatus) string {
 			} else {
 				return statusVersion
 			}
-		} else if specVersion != "" {
-			if specVersion != repoVersion {
-				return fmt.Sprintf("%v (%v available)", specVersion, repoVersion)
-			} else {
-				return specVersion
-			}
+		}
+		if specVersion != repoVersion {
+			return fmt.Sprintf("%v (%v available)", specVersion, repoVersion)
 		} else {
-			return "n/a"
+			return specVersion
 		}
 	} else {
 		return ""
diff --git a/cmd/package-operator/main.go b/cmd/package-operator/main.go
index fd20369..8996683 100644
--- a/cmd/package-operator/main.go
+++ b/cmd/package-operator/main.go
@@ -36,6 +36,7 @@ import (
 	"github.com/glasskube/glasskube/internal/controller"
 	"github.com/glasskube/glasskube/internal/manifest/helm/flux"
 	"github.com/glasskube/glasskube/internal/manifest/plain"
+	"github.com/glasskube/glasskube/internal/webhook"
 	//+kubebuilder:scaffold:imports
 )
 
@@ -109,6 +110,14 @@ func main() {
 		setupLog.Error(err, "unable to create controller", "controller", "PackageInfo")
 		os.Exit(1)
 	}
+	if os.Getenv("ENABLE_WEBHOOKS") != "false" {
+		if err = (&webhook.PackageValidatingWebhook{
+			Client: mgr.GetClient(),
+		}).SetupWithManager(mgr); err != nil {
+			setupLog.Error(err, "unable to create webhook", "webhook", "Package")
+			os.Exit(1)
+		}
+	}
 	//+kubebuilder:scaffold:builder
 
 	if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
diff --git a/config/aio/kustomization.yaml b/config/aio/kustomization.yaml
index b19475c..4974a0c 100644
--- a/config/aio/kustomization.yaml
+++ b/config/aio/kustomization.yaml
@@ -1,3 +1,3 @@
 resources:
   - ../dependencies
-  - ../slim
+  - ../default
diff --git a/config/certmanager/certificate.yaml b/config/certmanager/certificate.yaml
new file mode 100644
index 0000000..8624d89
--- /dev/null
+++ b/config/certmanager/certificate.yaml
@@ -0,0 +1,39 @@
+# The following manifests contain a self-signed issuer CR and a certificate CR.
+# More document can be found at https://docs.cert-manager.io
+# WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes.
+apiVersion: cert-manager.io/v1
+kind: Issuer
+metadata:
+  labels:
+    app.kubernetes.io/name: certificate
+    app.kubernetes.io/instance: serving-cert
+    app.kubernetes.io/component: certificate
+    app.kubernetes.io/created-by: glasskube
+    app.kubernetes.io/part-of: glasskube
+    app.kubernetes.io/managed-by: kustomize
+  name: selfsigned-issuer
+  namespace: system
+spec:
+  selfSigned: {}
+---
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+  labels:
+    app.kubernetes.io/name: certificate
+    app.kubernetes.io/instance: serving-cert
+    app.kubernetes.io/component: certificate
+    app.kubernetes.io/created-by: glasskube
+    app.kubernetes.io/part-of: glasskube
+    app.kubernetes.io/managed-by: kustomize
+  name: serving-cert  # this name should match the one appeared in kustomizeconfig.yaml
+  namespace: system
+spec:
+  # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize
+  dnsNames:
+  - SERVICE_NAME.SERVICE_NAMESPACE.svc
+  - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local
+  issuerRef:
+    kind: Issuer
+    name: selfsigned-issuer
+  secretName: webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize
diff --git a/config/certmanager/kustomization.yaml b/config/certmanager/kustomization.yaml
new file mode 100644
index 0000000..bebea5a
--- /dev/null
+++ b/config/certmanager/kustomization.yaml
@@ -0,0 +1,5 @@
+resources:
+- certificate.yaml
+
+configurations:
+- kustomizeconfig.yaml
diff --git a/config/certmanager/kustomizeconfig.yaml b/config/certmanager/kustomizeconfig.yaml
new file mode 100644
index 0000000..cf6f89e
--- /dev/null
+++ b/config/certmanager/kustomizeconfig.yaml
@@ -0,0 +1,8 @@
+# This configuration is for teaching kustomize how to update name ref substitution
+nameReference:
+- kind: Issuer
+  group: cert-manager.io
+  fieldSpecs:
+  - kind: Certificate
+    group: cert-manager.io
+    path: spec/issuerRef/name
diff --git a/config/crd/bases/packages.glasskube.dev_packages.yaml b/config/crd/bases/packages.glasskube.dev_packages.yaml
index 03410fd..12b6130 100644
--- a/config/crd/bases/packages.glasskube.dev_packages.yaml
+++ b/config/crd/bases/packages.glasskube.dev_packages.yaml
@@ -58,10 +58,11 @@ spec:
                       from
                     type: string
                   version:
-                    description: Version of the package to install (optional)
+                    description: Version of the package to install
                     type: string
                 required:
                 - name
+                - version
                 type: object
             required:
             - packageInfo
@@ -158,6 +159,26 @@ spec:
                   - version
                   type: object
                 type: array
+              ownedPackages:
+                items:
+                  properties:
+                    group:
+                      type: string
+                    kind:
+                      type: string
+                    name:
+                      type: string
+                    namespace:
+                      type: string
+                    version:
+                      type: string
+                  required:
+                  - group
+                  - kind
+                  - name
+                  - version
+                  type: object
+                type: array
               ownedResources:
                 items:
                   properties:
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index 126cb29..a86de21 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -9,7 +9,7 @@ resources:
 patches:
 # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix.
 # patches here are for enabling the conversion webhook for each CRD
-#- path: patches/webhook_in_packages.yaml
+- path: patches/webhook_in_packages.yaml
 #- path: patches/webhook_in_packageinfos.yaml
 #+kubebuilder:scaffold:crdkustomizewebhookpatch
 
@@ -22,5 +22,5 @@ patches:
 # [WEBHOOK] To enable webhook, uncomment the following section
 # the following config is for teaching kustomize how to do kustomization for CRDs.
 
-#configurations:
-#- kustomizeconfig.yaml
+configurations:
+- kustomizeconfig.yaml
diff --git a/config/crd/patches/cainjection_in_packages.yaml b/config/crd/patches/cainjection_in_packages.yaml
new file mode 100644
index 0000000..20c354d
--- /dev/null
+++ b/config/crd/patches/cainjection_in_packages.yaml
@@ -0,0 +1,7 @@
+# The following patch adds a directive for certmanager to inject CA into the CRD
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME
+  name: packages.packages.glasskube.dev
diff --git a/config/crd/patches/webhook_in_packages.yaml b/config/crd/patches/webhook_in_packages.yaml
new file mode 100644
index 0000000..758fd93
--- /dev/null
+++ b/config/crd/patches/webhook_in_packages.yaml
@@ -0,0 +1,16 @@
+# The following patch enables a conversion webhook for the CRD
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: packages.packages.glasskube.dev
+spec:
+  conversion:
+    strategy: Webhook
+    webhook:
+      clientConfig:
+        service:
+          namespace: system
+          name: webhook-service
+          path: /convert
+      conversionReviewVersions:
+      - v1
diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml
new file mode 100644
index 0000000..68fe455
--- /dev/null
+++ b/config/default/kustomization.yaml
@@ -0,0 +1,45 @@
+# Adds namespace to all resources.
+namespace: glasskube-system
+
+# Value of this field is prepended to the
+# names of all resources, e.g. a deployment named
+# "wordpress" becomes "alices-wordpress".
+# Note that it should also match with the prefix (text before '-') of the namespace
+# field above.
+namePrefix: glasskube-
+
+# Labels to add to all resources and selectors.
+#labels:
+#- includeSelectors: true
+#  pairs:
+#    someName: someValue
+
+# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
+# crd/kustomization.yaml
+resources:
+  - ../crd
+  - ../rbac
+  - ../manager
+  - ../webhook
+  - ../webhook-cert
+# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
+#- ../certmanager
+# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
+#- ../prometheus
+
+# Protect the /metrics endpoint by putting it behind auth.
+# If you want your controller-manager to expose the /metrics
+# endpoint w/o any authn/z, please comment the following line.
+
+images:
+  - name: controller
+    newName: ghcr.io/glasskube/package-operator
+    newTag: v0.0.4 # x-release-please-version
+
+# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
+# crd/kustomization.yaml
+patches:
+  - path: manager_auth_proxy_patch.yaml
+  - path: manager_webhook_patch.yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
diff --git a/config/slim/manager_auth_proxy_patch.yaml b/config/default/manager_auth_proxy_patch.yaml
similarity index 100%
rename from config/slim/manager_auth_proxy_patch.yaml
rename to config/default/manager_auth_proxy_patch.yaml
diff --git a/config/slim/manager_config_patch.yaml b/config/default/manager_config_patch.yaml
similarity index 100%
rename from config/slim/manager_config_patch.yaml
rename to config/default/manager_config_patch.yaml
diff --git a/config/default/manager_webhook_patch.yaml b/config/default/manager_webhook_patch.yaml
new file mode 100644
index 0000000..4ddc52f
--- /dev/null
+++ b/config/default/manager_webhook_patch.yaml
@@ -0,0 +1,22 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: controller-manager
+  namespace: system
+spec:
+  template:
+    spec:
+      containers:
+        - name: manager
+          ports:
+            - containerPort: 9443
+              name: webhook-server
+              protocol: TCP
+          volumeMounts:
+            - mountPath: /tmp/k8s-webhook-server/serving-certs
+              name: cert
+              readOnly: true
+      volumes:
+        - name: cert
+          secret:
+            secretName: glasskube-webhook-tls
diff --git a/config/default/webhookcainjection_patch.yaml b/config/default/webhookcainjection_patch.yaml
new file mode 100644
index 0000000..0fca321
--- /dev/null
+++ b/config/default/webhookcainjection_patch.yaml
@@ -0,0 +1,29 @@
+# This patch add annotation to admission webhook config and
+# CERTIFICATE_NAMESPACE and CERTIFICATE_NAME will be substituted by kustomize
+apiVersion: admissionregistration.k8s.io/v1
+kind: MutatingWebhookConfiguration
+metadata:
+  labels:
+    app.kubernetes.io/name: mutatingwebhookconfiguration
+    app.kubernetes.io/instance: mutating-webhook-configuration
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/created-by: glasskube
+    app.kubernetes.io/part-of: glasskube
+    app.kubernetes.io/managed-by: kustomize
+  name: mutating-webhook-configuration
+  annotations:
+    cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME
+---
+apiVersion: admissionregistration.k8s.io/v1
+kind: ValidatingWebhookConfiguration
+metadata:
+  labels:
+    app.kubernetes.io/name: validatingwebhookconfiguration
+    app.kubernetes.io/instance: validating-webhook-configuration
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/created-by: glasskube
+    app.kubernetes.io/part-of: glasskube
+    app.kubernetes.io/managed-by: kustomize
+  name: validating-webhook-configuration
+  annotations:
+    cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME
diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml
index a576a9b..90307c3 100644
--- a/config/manager/kustomization.yaml
+++ b/config/manager/kustomization.yaml
@@ -3,4 +3,4 @@ resources:
 images:
   - name: controller
     newName: ghcr.io/glasskube/package-operator
-    newTag: v0.0.3 # x-release-please-version
+    newTag: v0.0.4 # x-release-please-version
diff --git a/config/slim/kustomization.yaml b/config/slim/kustomization.yaml
index 0c2fc7a..1827738 100644
--- a/config/slim/kustomization.yaml
+++ b/config/slim/kustomization.yaml
@@ -1,142 +1,2 @@
-# Adds namespace to all resources.
-namespace: glasskube-system
-
-# Value of this field is prepended to the
-# names of all resources, e.g. a deployment named
-# "wordpress" becomes "alices-wordpress".
-# Note that it should also match with the prefix (text before '-') of the namespace
-# field above.
-namePrefix: glasskube-
-
-# Labels to add to all resources and selectors.
-#labels:
-#- includeSelectors: true
-#  pairs:
-#    someName: someValue
-
 resources:
-- ../crd
-- ../rbac
-- ../manager
-# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
-# crd/kustomization.yaml
-#- ../webhook
-# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
-#- ../certmanager
-# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
-#- ../prometheus
-
-patches:
-# Protect the /metrics endpoint by putting it behind auth.
-# If you want your controller-manager to expose the /metrics
-# endpoint w/o any authn/z, please comment the following line.
-- path: manager_auth_proxy_patch.yaml
-
-# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
-# crd/kustomization.yaml
-#- path: manager_webhook_patch.yaml
-
-# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'.
-# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks.
-# 'CERTMANAGER' needs to be enabled to use ca injection
-#- path: webhookcainjection_patch.yaml
-
-# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
-# Uncomment the following replacements to add the cert-manager CA injection annotations
-#replacements:
-#  - source: # Add cert-manager annotation to ValidatingWebhookConfiguration, MutatingWebhookConfiguration and CRDs
-#      kind: Certificate
-#      group: cert-manager.io
-#      version: v1
-#      name: serving-cert # this name should match the one in certificate.yaml
-#      fieldPath: .metadata.namespace # namespace of the certificate CR
-#    targets:
-#      - select:
-#          kind: ValidatingWebhookConfiguration
-#        fieldPaths:
-#          - .metadata.annotations.[cert-manager.io/inject-ca-from]
-#        options:
-#          delimiter: '/'
-#          index: 0
-#          create: true
-#      - select:
-#          kind: MutatingWebhookConfiguration
-#        fieldPaths:
-#          - .metadata.annotations.[cert-manager.io/inject-ca-from]
-#        options:
-#          delimiter: '/'
-#          index: 0
-#          create: true
-#      - select:
-#          kind: CustomResourceDefinition
-#        fieldPaths:
-#          - .metadata.annotations.[cert-manager.io/inject-ca-from]
-#        options:
-#          delimiter: '/'
-#          index: 0
-#          create: true
-#  - source:
-#      kind: Certificate
-#      group: cert-manager.io
-#      version: v1
-#      name: serving-cert # this name should match the one in certificate.yaml
-#      fieldPath: .metadata.name
-#    targets:
-#      - select:
-#          kind: ValidatingWebhookConfiguration
-#        fieldPaths:
-#          - .metadata.annotations.[cert-manager.io/inject-ca-from]
-#        options:
-#          delimiter: '/'
-#          index: 1
-#          create: true
-#      - select:
-#          kind: MutatingWebhookConfiguration
-#        fieldPaths:
-#          - .metadata.annotations.[cert-manager.io/inject-ca-from]
-#        options:
-#          delimiter: '/'
-#          index: 1
-#          create: true
-#      - select:
-#          kind: CustomResourceDefinition
-#        fieldPaths:
-#          - .metadata.annotations.[cert-manager.io/inject-ca-from]
-#        options:
-#          delimiter: '/'
-#          index: 1
-#          create: true
-#  - source: # Add cert-manager annotation to the webhook Service
-#      kind: Service
-#      version: v1
-#      name: webhook-service
-#      fieldPath: .metadata.name # namespace of the service
-#    targets:
-#      - select:
-#          kind: Certificate
-#          group: cert-manager.io
-#          version: v1
-#        fieldPaths:
-#          - .spec.dnsNames.0
-#          - .spec.dnsNames.1
-#        options:
-#          delimiter: '.'
-#          index: 0
-#          create: true
-#  - source:
-#      kind: Service
-#      version: v1
-#      name: webhook-service
-#      fieldPath: .metadata.namespace # namespace of the service
-#    targets:
-#      - select:
-#          kind: Certificate
-#          group: cert-manager.io
-#          version: v1
-#        fieldPaths:
-#          - .spec.dnsNames.0
-#          - .spec.dnsNames.1
-#        options:
-#          delimiter: '.'
-#          index: 1
-#          create: true
+  - ../default
diff --git a/config/webhook-cert/cronjob.yaml b/config/webhook-cert/cronjob.yaml
new file mode 100644
index 0000000..e105816
--- /dev/null
+++ b/config/webhook-cert/cronjob.yaml
@@ -0,0 +1,31 @@
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+  name: webhook-cert
+  namespace: system
+spec:
+  schedule: "0 0 1 * *"
+  jobTemplate:
+    spec:
+      template:
+        spec:
+          containers:
+            - name: manager
+              image: controller:latest
+              command:
+                - /cert-manager
+              securityContext:
+                allowPrivilegeEscalation: false
+                capabilities:
+                  drop:
+                    - "ALL"
+              resources:
+                limits:
+                  cpu: 500m
+                  memory: 128Mi
+                requests:
+                  cpu: 10m
+                  memory: 64Mi
+          serviceAccountName: webhook-cert
+          terminationGracePeriodSeconds: 3
+          restartPolicy: OnFailure
diff --git a/config/webhook-cert/job.yaml b/config/webhook-cert/job.yaml
new file mode 100644
index 0000000..e641cc8
--- /dev/null
+++ b/config/webhook-cert/job.yaml
@@ -0,0 +1,27 @@
+apiVersion: batch/v1
+kind: Job
+metadata:
+  name: webhook-cert-init
+  namespace: system
+spec:
+  template:
+    spec:
+      containers:
+        - name: manager
+          image: controller:latest
+          command:
+            - /cert-manager
+          securityContext:
+            allowPrivilegeEscalation: false
+            capabilities:
+              drop:
+                - "ALL"
+          resources:
+            limits:
+              cpu: 500m
+              memory: 128Mi
+            requests:
+              cpu: 10m
+              memory: 64Mi
+      serviceAccountName: webhook-cert
+      restartPolicy: OnFailure
diff --git a/config/webhook-cert/kustomization.yaml b/config/webhook-cert/kustomization.yaml
new file mode 100644
index 0000000..f944975
--- /dev/null
+++ b/config/webhook-cert/kustomization.yaml
@@ -0,0 +1,4 @@
+resources:
+  - job.yaml
+  - cronjob.yaml
+  - rbac.yaml
diff --git a/config/webhook-cert/rbac.yaml b/config/webhook-cert/rbac.yaml
new file mode 100644
index 0000000..010e043
--- /dev/null
+++ b/config/webhook-cert/rbac.yaml
@@ -0,0 +1,68 @@
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  labels:
+    app.kubernetes.io/name: serviceaccount
+    app.kubernetes.io/instance: controller-manager-sa
+    app.kubernetes.io/component: rbac
+    app.kubernetes.io/created-by: glasskube
+    app.kubernetes.io/part-of: glasskube
+    app.kubernetes.io/managed-by: kustomize
+  name: webhook-cert
+  namespace: system
+
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  name: webhook-cert-role
+  namespace: system
+rules:
+  - apiGroups:
+      - ""
+    resources:
+      - secrets
+    verbs:
+      - patch
+
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: webhook-cert-rolebinding
+  namespace: system
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: webhook-cert-role
+subjects:
+  - kind: ServiceAccount
+    name: webhook-cert
+    namespace: system
+
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: webhook-cert-role
+rules:
+  - apiGroups:
+      - admissionregistration.k8s.io
+    resources:
+      - validatingwebhookconfigurations
+    verbs:
+      - patch
+
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: webhook-cert-rolebinding
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: webhook-cert-role
+subjects:
+  - kind: ServiceAccount
+    name: webhook-cert
+    namespace: system
diff --git a/config/webhook-local/kustomization.yaml b/config/webhook-local/kustomization.yaml
new file mode 100644
index 0000000..ea269d2
--- /dev/null
+++ b/config/webhook-local/kustomization.yaml
@@ -0,0 +1,8 @@
+namespace: glasskube-system
+namePrefix: glasskube-
+resources:
+  - namespace.yaml
+  - ../webhook
+patches:
+  - path: webhook_manifests_patch.yaml
+  - path: webhook_service_patch.yaml
diff --git a/config/webhook-local/namespace.yaml b/config/webhook-local/namespace.yaml
new file mode 100644
index 0000000..ba95718
--- /dev/null
+++ b/config/webhook-local/namespace.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+  labels:
+    control-plane: controller-manager
+    app.kubernetes.io/name: namespace
+    app.kubernetes.io/instance: system
+    app.kubernetes.io/component: manager
+    app.kubernetes.io/created-by: glasskube
+    app.kubernetes.io/part-of: glasskube
+    app.kubernetes.io/managed-by: kustomize
+  name: system
diff --git a/config/webhook-local/webhook_manifests_patch.yaml b/config/webhook-local/webhook_manifests_patch.yaml
new file mode 100644
index 0000000..97de453
--- /dev/null
+++ b/config/webhook-local/webhook_manifests_patch.yaml
@@ -0,0 +1,9 @@
+apiVersion: admissionregistration.k8s.io/v1
+kind: ValidatingWebhookConfiguration
+metadata:
+  name: validating-webhook-configuration
+webhooks:
+  - clientConfig:
+      service:
+        port: 9443
+    name: vpackage.kb.io
diff --git a/config/webhook-local/webhook_service_patch.yaml b/config/webhook-local/webhook_service_patch.yaml
new file mode 100644
index 0000000..193fdf2
--- /dev/null
+++ b/config/webhook-local/webhook_service_patch.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: webhook-service
+  namespace: system
+spec:
+  type: ExternalName
+  externalName: host.minikube.internal
diff --git a/config/webhook/kustomization.yaml b/config/webhook/kustomization.yaml
new file mode 100644
index 0000000..9cf2613
--- /dev/null
+++ b/config/webhook/kustomization.yaml
@@ -0,0 +1,6 @@
+resources:
+- manifests.yaml
+- service.yaml
+
+configurations:
+- kustomizeconfig.yaml
diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml
new file mode 100644
index 0000000..206316e
--- /dev/null
+++ b/config/webhook/kustomizeconfig.yaml
@@ -0,0 +1,22 @@
+# the following config is for teaching kustomize where to look at when substituting nameReference.
+# It requires kustomize v2.1.0 or newer to work properly.
+nameReference:
+- kind: Service
+  version: v1
+  fieldSpecs:
+  - kind: MutatingWebhookConfiguration
+    group: admissionregistration.k8s.io
+    path: webhooks/clientConfig/service/name
+  - kind: ValidatingWebhookConfiguration
+    group: admissionregistration.k8s.io
+    path: webhooks/clientConfig/service/name
+
+namespace:
+- kind: MutatingWebhookConfiguration
+  group: admissionregistration.k8s.io
+  path: webhooks/clientConfig/service/namespace
+  create: true
+- kind: ValidatingWebhookConfiguration
+  group: admissionregistration.k8s.io
+  path: webhooks/clientConfig/service/namespace
+  create: true
diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml
new file mode 100644
index 0000000..f2ec5cc
--- /dev/null
+++ b/config/webhook/manifests.yaml
@@ -0,0 +1,27 @@
+---
+apiVersion: admissionregistration.k8s.io/v1
+kind: ValidatingWebhookConfiguration
+metadata:
+  name: validating-webhook-configuration
+webhooks:
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: system
+      path: /validate-packages-glasskube-dev-v1alpha1-package
+  failurePolicy: Fail
+  name: vpackage.kb.io
+  rules:
+  - apiGroups:
+    - packages.glasskube.dev
+    apiVersions:
+    - v1alpha1
+    operations:
+    - CREATE
+    - UPDATE
+    - DELETE
+    resources:
+    - packages
+  sideEffects: None
diff --git a/config/webhook/service.yaml b/config/webhook/service.yaml
new file mode 100644
index 0000000..b38fb6f
--- /dev/null
+++ b/config/webhook/service.yaml
@@ -0,0 +1,19 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    app.kubernetes.io/name: service
+    app.kubernetes.io/instance: webhook-service
+    app.kubernetes.io/component: webhook
+    app.kubernetes.io/created-by: glasskube
+    app.kubernetes.io/part-of: glasskube
+    app.kubernetes.io/managed-by: kustomize
+  name: webhook-service
+  namespace: system
+spec:
+  ports:
+    - port: 443
+      protocol: TCP
+      targetPort: 9443
+  selector:
+    control-plane: controller-manager
diff --git a/go.mod b/go.mod
index 127c503..bc5cc2a 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
 	github.com/fatih/color v1.16.0
 	github.com/fluxcd/helm-controller/api v0.37.4
 	github.com/fluxcd/source-controller/api v1.2.4
+	github.com/go-logr/logr v1.4.1
 	github.com/google/go-containerregistry v0.19.0
 	github.com/gorilla/mux v1.8.1
 	github.com/gorilla/websocket v1.5.1
@@ -32,12 +33,12 @@ require (
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/emicklei/go-restful/v3 v3.11.2 // indirect
+	github.com/evanphx/json-patch v4.12.0+incompatible // indirect
 	github.com/evanphx/json-patch/v5 v5.8.0 // indirect
 	github.com/fluxcd/pkg/apis/acl v0.1.0 // indirect
 	github.com/fluxcd/pkg/apis/kustomize v1.3.0 // indirect
 	github.com/fluxcd/pkg/apis/meta v1.3.0 // indirect
 	github.com/fsnotify/fsnotify v1.7.0 // indirect
-	github.com/go-logr/logr v1.4.1 // indirect
 	github.com/go-logr/zapr v1.3.0 // indirect
 	github.com/go-openapi/jsonpointer v0.20.2 // indirect
 	github.com/go-openapi/jsonreference v0.20.4 // indirect
@@ -85,7 +86,7 @@ require (
 	golang.org/x/tools v0.17.0 // indirect
 	gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
-	google.golang.org/protobuf v1.32.0 // indirect
+	google.golang.org/protobuf v1.33.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/go.sum b/go.sum
index 4013d19..61e9f46 100644
--- a/go.sum
+++ b/go.sum
@@ -221,8 +221,8 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs
 google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
-google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/internal/certificates/certificates.go b/internal/certificates/certificates.go
new file mode 100644
index 0000000..dda0743
--- /dev/null
+++ b/internal/certificates/certificates.go
@@ -0,0 +1,59 @@
+package certificates
+
+import (
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"fmt"
+	"math/big"
+	"time"
+)
+
+var (
+	// DefaultValidity is 365 days
+	DefaultValidity = 365 * 24 * time.Hour
+)
+
+type Certificates struct{ Webhook, Ca *KeyPair }
+
+func Generate(serviceName, serviceNamespace string, validity time.Duration) (*Certificates, error) {
+	var certificates Certificates
+
+	notBefore := time.Now()
+	notAfter := notBefore.Add(validity)
+	caCertificateConfig := &x509.Certificate{
+		SerialNumber:          big.NewInt(0),
+		Subject:               pkix.Name{Organization: []string{"glasskube.dev"}},
+		NotBefore:             notBefore,
+		NotAfter:              notAfter,
+		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+	}
+
+	if kp, err := GenerateKeyPair(caCertificateConfig, nil); err != nil {
+		return nil, fmt.Errorf("could not create CA key pair: %w", err)
+	} else {
+		certificates.Ca = kp
+	}
+
+	serviceNameWithNamespace := fmt.Sprintf("%v.%v", serviceName, serviceNamespace)
+	commonName := fmt.Sprintf("%v.svc", serviceNameWithNamespace)
+	webhookCertificateConfig := &x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject:      pkix.Name{CommonName: commonName},
+		DNSNames:     []string{serviceName, serviceNameWithNamespace, commonName},
+		NotBefore:    notBefore,
+		NotAfter:     notAfter,
+		KeyUsage:     x509.KeyUsageDigitalSignature,
+		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+	}
+
+	if kp, err := GenerateKeyPair(webhookCertificateConfig, certificates.Ca); err != nil {
+		return nil, fmt.Errorf("could not create webhook key pair: %w", err)
+	} else {
+		certificates.Webhook = kp
+	}
+
+	return &certificates, nil
+}
diff --git a/internal/certificates/keypair.go b/internal/certificates/keypair.go
new file mode 100644
index 0000000..194b2b2
--- /dev/null
+++ b/internal/certificates/keypair.go
@@ -0,0 +1,86 @@
+package certificates
+
+import (
+	"bytes"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"fmt"
+	"os"
+	"path"
+
+	"go.uber.org/multierr"
+)
+
+type EncodedKeyPair struct{ Key, Cert []byte }
+
+func (kp *EncodedKeyPair) SaveTo(dir string) error {
+	if err := os.MkdirAll(dir, 0740); err != nil {
+		return err
+	}
+	var err error
+	for name, data := range kp.AsMap() {
+		err = multierr.Append(err, os.WriteFile(path.Join(dir, name), data, 0640))
+	}
+	return err
+}
+
+func (kp *EncodedKeyPair) AsMap() map[string][]byte {
+	return map[string][]byte{
+		"tls.key": kp.Key,
+		"tls.crt": kp.Cert,
+	}
+}
+
+type KeyPair struct {
+	Key  *rsa.PrivateKey
+	Cert *x509.Certificate
+}
+
+// Encoded returns the PEM encoded key pair
+func (kp *KeyPair) Encoded() (*EncodedKeyPair, error) {
+	var cb, kb bytes.Buffer
+
+	if err := pem.Encode(&cb, &pem.Block{Type: "CERTIFICATE", Bytes: kp.Cert.Raw}); err != nil {
+		return nil, fmt.Errorf("could not encode key: %w", err)
+	}
+
+	pkcs1 := x509.MarshalPKCS1PrivateKey(kp.Key)
+	if err := pem.Encode(&kb, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: pkcs1}); err != nil {
+		return nil, fmt.Errorf("could not encode cert: %w", err)
+	}
+
+	return &EncodedKeyPair{Key: kb.Bytes(), Cert: cb.Bytes()}, nil
+}
+
+// GenerateKeyPair generates a private key and certificate for a given certificate config.
+// If ca is nil, a new self-signed CA keypair is generated.
+func GenerateKeyPair(config *x509.Certificate, ca *KeyPair) (*KeyPair, error) {
+	var kp KeyPair
+
+	if pk, err := rsa.GenerateKey(rand.Reader, 4096); err != nil {
+		return nil, err
+	} else {
+		kp.Key = pk
+	}
+
+	// When generating a CA key pair, parent must be the same as config and the generated private key is used
+	parentCert := config
+	parentKey := kp.Key
+	if ca != nil {
+		parentCert = ca.Cert
+		parentKey = ca.Key
+	}
+
+	if certBytes, err := x509.CreateCertificate(
+		rand.Reader, config, parentCert, &kp.Key.PublicKey, parentKey); err != nil {
+		return nil, err
+	} else if cert, err := x509.ParseCertificate(certBytes); err != nil {
+		return nil, err
+	} else {
+		kp.Cert = cert
+	}
+
+	return &kp, nil
+}
diff --git a/internal/cliutils/checkforupdate.go b/internal/cliutils/checkforupdate.go
index 88289bb..630d3d5 100644
--- a/internal/cliutils/checkforupdate.go
+++ b/internal/cliutils/checkforupdate.go
@@ -34,22 +34,27 @@ func versionIsGreater(version1, version2 string) bool {
 	return len(v1) < len(v2)
 }
 
-func checkForUpdate() (bool, string) {
+func FetchLatestRelease() (*ReleaseInfo, error) {
 	url := "https://glasskube.dev/release.json"
 
 	resp, _ := http.Get(url)
 
 	defer func() {
 		_ = resp.Body.Close()
-
 	}()
 
 	var releaseInfo ReleaseInfo
 	if err := json.NewDecoder(resp.Body).Decode(&releaseInfo); err != nil {
-		return false, ""
+		return nil, err
 	}
 
-	if versionIsGreater(config.Version, releaseInfo.Version) {
+	return &releaseInfo, nil
+}
+
+func CheckForUpdate() (bool, string) {
+	if releaseInfo, err := FetchLatestRelease(); err != nil {
+		return false, ""
+	} else if versionIsGreater(config.Version, releaseInfo.Version) {
 		return true, releaseInfo.Version
 	}
 
@@ -57,7 +62,7 @@ func checkForUpdate() (bool, string) {
 }
 
 func UpdateFetch() {
-	updateAvailable, latestVersion := checkForUpdate()
+	updateAvailable, latestVersion := CheckForUpdate()
 	if updateAvailable {
 		fmt.Fprintf(os.Stderr, "📣 A newer version of Glasskube is available: %s → %s\n", config.Version, latestVersion)
 		fmt.Fprintf(os.Stderr, "📘 Release notes: https://github.com/glasskube/glasskube/releases/tag/v%v\n\n", latestVersion)
diff --git a/internal/controller/owners/owners.go b/internal/controller/owners/owners.go
index 67ec954..abaa982 100644
--- a/internal/controller/owners/owners.go
+++ b/internal/controller/owners/owners.go
@@ -3,6 +3,7 @@ package owners
 import (
 	"errors"
 
+	"github.com/glasskube/glasskube/internal/controller/owners/utils"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
@@ -41,16 +42,41 @@ func (mgr *OwnerManager) HasOwner(owner client.Object, obj metav1.Object) (bool,
 }
 
 func (mgr *OwnerManager) HasAnyOwnerOfType(owner client.Object, obj metav1.Object) (bool, error) {
+	filtered, err := mgr.OwnersOfType(owner, obj)
+	return len(filtered) > 0, err
+}
+
+func (mgr *OwnerManager) OwnersOfType(owner client.Object, obj metav1.Object) ([]metav1.OwnerReference, error) {
+	ownerGVK, err := utils.GetGVK(mgr.scheme, owner)
+	if err != nil {
+		return nil, err
+	}
+	ownerGV := schema.GroupVersionKind(ownerGVK).GroupVersion()
+	var filtered []metav1.OwnerReference
+	for _, ref := range obj.GetOwnerReferences() {
+		if ownerGVK.Kind == ref.Kind {
+			if refGV, err := schema.ParseGroupVersion(ref.APIVersion); err != nil {
+				return filtered, err
+			} else if ownerGV == refGV {
+				filtered = append(filtered, ref)
+			}
+		}
+	}
+	return filtered, nil
+}
+
+func (mgr *OwnerManager) CountOwnersOfType(owner client.Object, obj metav1.Object) (int, error) {
 	ownerGVK := owner.GetObjectKind().GroupVersionKind()
 	ownerGV := ownerGVK.GroupVersion()
+	count := 0
 	for _, ref := range obj.GetOwnerReferences() {
 		if refGV, err := schema.ParseGroupVersion(ref.APIVersion); err != nil {
-			return false, err
+			return 0, err
 		} else if ownerGV == refGV {
-			return true, nil
+			count = count + 1
 		}
 	}
-	return false, nil
+	return count, nil
 }
 
 func (mgr *OwnerManager) SetOwner(
@@ -80,6 +106,10 @@ func (mgr *OwnerManager) SetOwner(
 	return nil
 }
 
+func (mgr *OwnerManager) RemoveOwner(owner client.Object, obj metav1.Object) error {
+	return controllerutil.RemoveOwnerReference(owner, obj, mgr.scheme)
+}
+
 func (mgr *OwnerManager) findOwnerReferenceIndex(owner client.Object, references []metav1.OwnerReference) (int, error) {
 	ownerName := owner.GetName()
 	ownerGVK := owner.GetObjectKind().GroupVersionKind()
diff --git a/internal/controller/owners/utils/owned_resources.go b/internal/controller/owners/utils/owned_resources.go
index b6e67b6..f0f583d 100644
--- a/internal/controller/owners/utils/owned_resources.go
+++ b/internal/controller/owners/utils/owned_resources.go
@@ -66,7 +66,7 @@ func RemoveOwnedResourceRef(refs *[]packagesv1alpha1.OwnedResourceRef, ref packa
 }
 
 func ToOwnedResourceRef(scheme *runtime.Scheme, obj client.Object) (packagesv1alpha1.OwnedResourceRef, error) {
-	if gvk, err := getGVK(scheme, obj); err != nil {
+	if gvk, err := GetGVK(scheme, obj); err != nil {
 		return packagesv1alpha1.OwnedResourceRef{}, err
 	} else {
 		return packagesv1alpha1.OwnedResourceRef{
@@ -85,7 +85,7 @@ func OwnedResourceRefToObject(ref packagesv1alpha1.OwnedResourceRef) client.Obje
 	return &obj
 }
 
-func getGVK(scheme *runtime.Scheme, obj client.Object) (metav1.GroupVersionKind, error) {
+func GetGVK(scheme *runtime.Scheme, obj client.Object) (metav1.GroupVersionKind, error) {
 	if gvks, _, err := scheme.ObjectKinds(obj); err != nil {
 		return metav1.GroupVersionKind{}, err
 	} else {
diff --git a/internal/controller/package_controller.go b/internal/controller/package_controller.go
index a70c39f..0ba2dfc 100644
--- a/internal/controller/package_controller.go
+++ b/internal/controller/package_controller.go
@@ -127,6 +127,7 @@ type PackageReconcilationContext struct {
 	isSuccess             bool
 	shouldUpdateStatus    bool
 	currentOwnedResources []v1alpha1.OwnedResourceRef
+	currentOwnedPackages  []v1alpha1.OwnedResourceRef
 }
 
 func (r *PackageReconcilationContext) setShouldUpdate(value bool) {
@@ -229,7 +230,7 @@ func (r *PackageReconcilationContext) ensureDependencies(ctx context.Context) bo
 	log.V(1).Info("ensuring dependencies", "dependencies", r.pi.Status.Manifest.Dependencies)
 
 	var failed []string
-	if result, err := r.dependencyMgr.Validate(ctx, r.pkg, r.pi); err != nil {
+	if result, err := r.dependencyMgr.Validate(ctx, r.pkg, r.pi.Status.Manifest); err != nil {
 		r.setShouldUpdate(
 			conditions.SetFailed(ctx, r.EventRecorder, r.pkg, &r.pkg.Status.Conditions,
 				condition.InstallationFailed, fmt.Sprintf("error validating dependencies: %v", err)))
@@ -275,6 +276,7 @@ func (r *PackageReconcilationContext) ensureDependencies(ctx context.Context) bo
 		return false
 	}
 
+	var ownedPackages []packagesv1alpha1.OwnedResourceRef
 	var waitingFor []string
 	// if all requirements fulfilled, status can be checked
 	for _, dep := range r.pi.Status.Manifest.Dependencies {
@@ -307,6 +309,13 @@ func (r *PackageReconcilationContext) ensureDependencies(ctx context.Context) bo
 					}
 				}
 			}
+
+			if owned, err := ownerutils.ToOwnedResourceRef(r.Scheme, &requiredPkg); err != nil {
+				log.Error(err, "Failed to create OwnedResourceRef", "package", requiredPkg)
+				failed = append(failed, dep.Name)
+			} else {
+				ownedPackages = append(ownedPackages, owned)
+			}
 			if meta.IsStatusConditionTrue(requiredPkg.Status.Conditions, string(condition.Failed)) {
 				failed = append(failed, requiredPkg.Name)
 			} else if !meta.IsStatusConditionTrue(requiredPkg.Status.Conditions, string(condition.Ready)) {
@@ -327,6 +336,8 @@ func (r *PackageReconcilationContext) ensureDependencies(ctx context.Context) bo
 		return false
 	}
 
+	ownerutils.Add(&r.currentOwnedPackages, ownedPackages...)
+
 	return true
 }
 
@@ -377,11 +388,7 @@ func (r *PackageReconcilationContext) ensurePackageInfo(ctx context.Context) err
 }
 
 func generatePackageInfoName(pkg packagesv1alpha1.Package) string {
-	if pkg.Spec.PackageInfo.Version != "" {
-		return escapeResourceName(fmt.Sprintf("%v--%v", pkg.Spec.PackageInfo.Name, pkg.Spec.PackageInfo.Version))
-	} else {
-		return escapeResourceName(pkg.Spec.PackageInfo.Name)
-	}
+	return escapeResourceName(fmt.Sprintf("%v--%v", pkg.Spec.PackageInfo.Name, pkg.Spec.PackageInfo.Version))
 }
 
 func (r *PackageReconcilationContext) handleAdapterResults(ctx context.Context, results []result.ReconcileResult) bool {
@@ -444,6 +451,7 @@ func (r *PackageReconcilationContext) actualFinalize(ctx context.Context) error
 	log := ctrl.LoggerFrom(ctx)
 
 	r.setShouldUpdate(ownerutils.Add(&r.pkg.Status.OwnedResources, r.currentOwnedResources...))
+	r.setShouldUpdate(ownerutils.Add(&r.pkg.Status.OwnedPackages, r.currentOwnedPackages...))
 
 	var errs error
 	if r.isSuccess {
@@ -472,6 +480,7 @@ func (r *PackageReconcilationContext) cleanup(ctx context.Context) error {
 	return multierr.Combine(
 		r.pruneOwnedResources(ctx),
 		r.pruneOwnedPackageInfos(ctx),
+		r.pruneOwnedPackages(ctx),
 	)
 }
 
@@ -548,3 +557,63 @@ func (r *PackageReconcilationContext) pruneOwnedPackageInfos(ctx context.Context
 	}
 	return compositeErr
 }
+
+func (r *PackageReconcilationContext) pruneOwnedPackages(ctx context.Context) error {
+	log := ctrl.LoggerFrom(ctx)
+	var errs error
+	ownedPackagesCopy := r.pkg.Status.OwnedPackages[:]
+OuterLoop:
+	for _, ref := range r.pkg.Status.OwnedPackages {
+		for _, newRef := range r.currentOwnedPackages {
+			if ownerutils.RefersToSameResource(ref, newRef) {
+				continue OuterLoop
+			}
+		}
+
+		var oldReqPkg v1alpha1.Package
+		if err := r.Get(ctx, types.NamespacedName{
+			Name:      ref.Name,
+			Namespace: ref.Namespace,
+		}, &oldReqPkg); err != nil {
+			if apierrors.IsNotFound(err) {
+				r.setShouldUpdate(ownerutils.Remove(&ownedPackagesCopy, ref))
+			} else {
+				log.Error(err, "Failed to get old required package", "oldPackage", ref.Name)
+			}
+		} else if owning, err := r.HasOwner(r.pkg, &oldReqPkg); err != nil {
+			log.Error(err, "Failed to check owner references of old required package", "oldPackage", ref.Name)
+		} else if owning {
+			var cnt int
+			cnt, err = r.CountOwnersOfType(r.pkg, &oldReqPkg)
+			if err != nil {
+				log.Error(err, "Failed to check owner references of old required package", "oldPackage", ref.Name)
+				continue
+			}
+			if err := r.RemoveOwner(r.pkg, &oldReqPkg); err != nil {
+				log.Error(err, "Failed to remove owner reference", "oldPackage", ref.Name)
+			} else if err := r.Update(ctx, &oldReqPkg); err != nil {
+				log.Error(err, "Failed to update old package with removed owner reference", "oldPackage", ref.Name)
+			} else {
+				r.setShouldUpdate(ownerutils.Remove(&ownedPackagesCopy, ref))
+				log.Info(fmt.Sprintf("Removed owner reference from %v to %v", r.pkg.Name, ref.Name))
+
+				if cnt == 1 {
+					// remove the old package if we were the only package owning it
+					deletePropagationForeground := metav1.DeletePropagationForeground
+					if err := r.Delete(ctx, ownerutils.OwnedResourceRefToObject(ref), &client.DeleteOptions{
+						PropagationPolicy: &deletePropagationForeground,
+					}); err != nil && !apierrors.IsNotFound(err) {
+						errs = multierr.Append(errs, fmt.Errorf("could not prune package: %w", err))
+					} else {
+						log.V(1).Info("pruned package", "reference", ref)
+						r.setShouldUpdate(ownerutils.Remove(&ownedPackagesCopy, ref))
+					}
+				}
+			}
+		} else {
+			r.setShouldUpdate(ownerutils.Remove(&ownedPackagesCopy, ref))
+		}
+	}
+	r.pkg.Status.OwnedPackages = ownedPackagesCopy
+	return errs
+}
diff --git a/internal/controller/packageinfo_controller.go b/internal/controller/packageinfo_controller.go
index 58e3b07..e8c735a 100644
--- a/internal/controller/packageinfo_controller.go
+++ b/internal/controller/packageinfo_controller.go
@@ -77,7 +77,7 @@ func (r *PackageInfoReconciler) Reconcile(ctx context.Context, req ctrl.Request)
 		}
 
 		log.Info("updating manifest")
-		if err := repo.UpdatePackageManifest(&packageInfo); err != nil {
+		if err := updatePackageManifest(&packageInfo); err != nil {
 			err1 := conditions.SetFailedAndUpdate(ctx, r.Client, r.EventRecorder, &packageInfo, &packageInfo.Status.Conditions,
 				condition.SyncFailed, err.Error())
 			return requeue.Always(ctx, multierr.Append(err, err1))
@@ -120,6 +120,16 @@ func shouldSyncFromRepo(pi packagesv1alpha1.PackageInfo) bool {
 		time.Since(pi.Status.LastUpdateTimestamp.Time) > repositorySyncInterval
 }
 
+func updatePackageManifest(pi *packagesv1alpha1.PackageInfo) (err error) {
+	var manifest packagesv1alpha1.PackageManifest
+	if err = repo.FetchPackageManifest(pi.Spec.RepositoryUrl, pi.Spec.Name, pi.Spec.Version, &manifest); err != nil {
+		return
+	}
+	pi.Status.Manifest = &manifest
+	pi.Status.Version = pi.Spec.Version
+	return nil
+}
+
 // SetupWithManager sets up the controller with the Manager.
 func (r *PackageInfoReconciler) SetupWithManager(mgr ctrl.Manager) error {
 	if r.OwnerManager == nil {
diff --git a/internal/dependency/manager.go b/internal/dependency/manager.go
index 36e3ac6..5e59719 100644
--- a/internal/dependency/manager.go
+++ b/internal/dependency/manager.go
@@ -3,13 +3,16 @@ package dependency
 import (
 	"context"
 	"errors"
+	"fmt"
 	"sort"
+	"strings"
 
 	"github.com/glasskube/glasskube/internal/dependency/adapter"
 
 	"github.com/Masterminds/semver/v3"
 	"github.com/glasskube/glasskube/api/v1alpha1"
 	repo2 "github.com/glasskube/glasskube/internal/repo"
+	"github.com/glasskube/glasskube/internal/repo/client"
 )
 
 type DependendcyManager struct {
@@ -39,22 +42,37 @@ type Conflict struct {
 	Required PackageWithVersion
 }
 
+func (cf Conflict) String() string {
+	return fmt.Sprintf("%v (required: %v, actual: %v)", cf.Required.Name, cf.Required.Version, cf.Actual.Version)
+}
+
+type Conflicts []Conflict
+
+func (cf Conflicts) String() string {
+	s := make([]string, len(cf))
+	for i, c := range cf {
+		s[i] = c.String()
+	}
+	return strings.Join(s, ", ")
+}
+
 type ValidationResult struct {
 	Status       ValidationResultStatus
 	Requirements []PackageWithVersion
-	Conflicts    []Conflict
+	Conflicts    Conflicts
 }
 
 type defaultRepoAdapter struct {
+	repo client.RepoClient
 }
 
 func (a *defaultRepoAdapter) GetLatestVersion(repo string, pkgName string) (string, error) {
-	return repo2.GetLatestVersion(repo, pkgName)
+	return a.repo.GetLatestVersion(repo, pkgName)
 }
 
 func (a *defaultRepoAdapter) GetMaxVersionCompatibleWith(repo string, pkgName string, versionRange string) (string, error) {
 	var idx repo2.PackageIndex
-	if err := repo2.FetchPackageIndex(repo, pkgName, &idx); err != nil {
+	if err := a.repo.FetchPackageIndex(repo, pkgName, &idx); err != nil {
 		return "", err
 	}
 	constraint, err := semver.NewConstraint(versionRange)
@@ -81,15 +99,19 @@ func (a *defaultRepoAdapter) GetMaxVersionCompatibleWith(repo string, pkgName st
 func NewDependencyManager(adapter adapter.ClientAdapter) *DependendcyManager {
 	return &DependendcyManager{
 		clientAdapter: adapter,
-		repoAdapter:   &defaultRepoAdapter{},
+		repoAdapter:   &defaultRepoAdapter{repo: repo2.DefaultClient},
 	}
 }
 
-func (dm *DependendcyManager) Validate(ctx context.Context, pkg *v1alpha1.Package, pkgInfo *v1alpha1.PackageInfo) (*ValidationResult, error) {
-	if pkg == nil || pkgInfo == nil || pkgInfo.Status.Manifest == nil {
+func (dm *DependendcyManager) WithRepo(repo client.RepoClient) *DependendcyManager {
+	dm.repoAdapter = &defaultRepoAdapter{repo: repo}
+	return dm
+}
+
+func (dm *DependendcyManager) Validate(ctx context.Context, pkg *v1alpha1.Package, piManifest *v1alpha1.PackageManifest) (*ValidationResult, error) {
+	if pkg == nil || piManifest == nil {
 		return nil, errors.New("nil not allowed")
 	}
-	piManifest := pkgInfo.Status.Manifest
 	var requirements []PackageWithVersion
 	var conflicts []Conflict
 
diff --git a/internal/repo/client/defaultclient.go b/internal/repo/client/defaultclient.go
new file mode 100644
index 0000000..ae41de4
--- /dev/null
+++ b/internal/repo/client/defaultclient.go
@@ -0,0 +1,116 @@
+package client
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"sync"
+	"time"
+
+	"github.com/glasskube/glasskube/api/v1alpha1"
+	"github.com/glasskube/glasskube/internal/httperror"
+	"github.com/glasskube/glasskube/internal/repo/types"
+	"k8s.io/apimachinery/pkg/util/yaml"
+)
+
+type defaultClient struct {
+	defaultRepositoryURL string
+	idxMutex             sync.Mutex
+	idxUpdate            time.Time
+	packageRepoIndex     types.PackageRepoIndex
+}
+
+func New(repoURL string) RepoClient {
+	return &defaultClient{defaultRepositoryURL: repoURL}
+}
+
+// FetchLatestPackageManifest implements repo.RepoClient.
+func (c *defaultClient) FetchLatestPackageManifest(repoURL string, name string, target *v1alpha1.PackageManifest) (version string, err error) {
+	var versions types.PackageIndex
+	if err = c.FetchPackageIndex(repoURL, name, &versions); err != nil {
+		return
+	} else {
+		version = versions.LatestVersion
+	}
+	err = c.FetchPackageManifest(repoURL, name, version, target)
+	return
+}
+
+// FetchPackageManifest implements repo.RepoClient.
+func (c *defaultClient) FetchPackageManifest(repoURL string, name string, version string, target *v1alpha1.PackageManifest) error {
+	if url, err := c.GetPackageManifestURL(repoURL, name, version); err != nil {
+		return err
+	} else {
+		return c.fetchYAMLOrJSON(url, target)
+	}
+}
+
+// FetchPackageIndex implements repo.RepoClient.
+func (c *defaultClient) FetchPackageIndex(repoURL string, name string, target *types.PackageIndex) error {
+	if url, err := c.getPackageIndexURL(repoURL, name); err != nil {
+		return err
+	} else {
+		return c.fetchYAMLOrJSON(url, target)
+	}
+}
+
+// FetchPackageRepoIndex implements repo.RepoClient.
+func (c *defaultClient) FetchPackageRepoIndex(repoURL string, target *types.PackageRepoIndex) error {
+	if url, err := c.getPackageRepoIndexURL(repoURL); err != nil {
+		return err
+	} else {
+		return c.fetchYAMLOrJSON(url, target)
+	}
+}
+
+// GetLatestVersion implements repo.RepoClient.
+func (c *defaultClient) GetLatestVersion(repoURL string, pkgName string) (string, error) {
+	c.idxMutex.Lock()
+	defer c.idxMutex.Unlock()
+	if len(c.packageRepoIndex.Packages) == 0 || c.idxUpdate.Add(5*time.Minute).Before(time.Now()) {
+		if err := c.FetchPackageRepoIndex(repoURL, &c.packageRepoIndex); err != nil {
+			return "", err
+		}
+		c.idxUpdate = time.Now()
+	}
+	for _, pkg := range c.packageRepoIndex.Packages {
+		if pkg.Name == pkgName {
+			return pkg.LatestVersion, nil
+		}
+	}
+	return "", nil
+}
+
+func (c *defaultClient) fetchYAMLOrJSON(url string, target any) error {
+	resp, err := http.Get(url)
+	if err != nil {
+		return err
+	}
+	defer func() { _ = resp.Body.Close() }()
+	if err = httperror.CheckResponse(resp); err != nil {
+		return fmt.Errorf("failed to fetch %v: %w", url, err)
+	}
+	return yaml.NewYAMLOrJSONDecoder(resp.Body, 4096).Decode(target)
+}
+
+func (c *defaultClient) getPackageRepoIndexURL(repoURL string) (string, error) {
+	return url.JoinPath(c.getBaseURL(repoURL), "index.yaml")
+}
+
+func (c *defaultClient) getPackageIndexURL(repoURL, name string) (string, error) {
+	return url.JoinPath(c.getBaseURL(repoURL), url.PathEscape(name), "versions.yaml")
+}
+
+// GetPackageManifestURL implements repo.RepoClient.
+func (c *defaultClient) GetPackageManifestURL(repoURL, name, version string) (string, error) {
+	pathSegments := []string{url.PathEscape(name), url.PathEscape(version), "package.yaml"}
+	return url.JoinPath(c.getBaseURL(repoURL), pathSegments...)
+}
+
+func (c *defaultClient) getBaseURL(explicitRepositoryURL string) string {
+	if len(explicitRepositoryURL) > 0 {
+		return explicitRepositoryURL
+	} else {
+		return c.defaultRepositoryURL
+	}
+}
diff --git a/internal/repo/client/fake/fakeclient.go b/internal/repo/client/fake/fakeclient.go
new file mode 100644
index 0000000..3a0bee0
--- /dev/null
+++ b/internal/repo/client/fake/fakeclient.go
@@ -0,0 +1,95 @@
+package fake
+
+import (
+	"errors"
+
+	"github.com/glasskube/glasskube/api/v1alpha1"
+	"github.com/glasskube/glasskube/internal/repo/client"
+	"github.com/glasskube/glasskube/internal/repo/types"
+)
+
+// FakeClient is a mock implementation of RepoClient for use in tests
+type FakeClient struct {
+	Packages map[string]map[string]v1alpha1.PackageManifest
+}
+
+func (f *FakeClient) AddPackage(name, version string, manifest v1alpha1.PackageManifest) {
+	if f.Packages == nil {
+		f.Clear()
+	}
+	if _, ok := f.Packages[name]; !ok {
+		f.Packages[name] = map[string]v1alpha1.PackageManifest{}
+	}
+	f.Packages[name][version] = manifest
+}
+
+func (f *FakeClient) Clear() {
+	f.Packages = map[string]map[string]v1alpha1.PackageManifest{}
+}
+
+var _ client.RepoClient = &FakeClient{}
+
+// FetchLatestPackageManifest implements client.RepoClient.
+func (f *FakeClient) FetchLatestPackageManifest(repoURL string, name string, target *v1alpha1.PackageManifest) (version string, err error) {
+	if versions, ok := f.Packages[name]; ok {
+		for v, m := range versions {
+			*target = m
+			return v, nil
+		}
+	}
+	return "", errors.New("not found")
+}
+
+// FetchPackageIndex implements client.RepoClient.
+func (f *FakeClient) FetchPackageIndex(repoURL string, name string, target *types.PackageIndex) error {
+	if versions, ok := f.Packages[name]; ok {
+		var result types.PackageIndex
+		for v := range versions {
+			result.LatestVersion = v
+			result.Versions = append(result.Versions, types.PackageIndexItem{Version: v})
+		}
+		*target = result
+		return nil
+	}
+	return errors.New("not found")
+}
+
+// FetchPackageManifest implements client.RepoClient.
+func (f *FakeClient) FetchPackageManifest(repoURL string, name string, version string, target *v1alpha1.PackageManifest) error {
+	if versions, ok := f.Packages[name]; ok {
+		if manifest, ok := versions[version]; ok {
+			*target = manifest
+			return nil
+		}
+	}
+	return errors.New("not found")
+}
+
+// FetchPackageRepoIndex implements client.RepoClient.
+func (f *FakeClient) FetchPackageRepoIndex(repoURL string, target *types.PackageRepoIndex) error {
+	var result types.PackageRepoIndex
+	for pkg, versions := range f.Packages {
+		item := types.PackageRepoIndexItem{Name: pkg}
+		for v := range versions {
+			item.LatestVersion = v
+		}
+		result.Packages = append(result.Packages, item)
+	}
+	*target = result
+	return nil
+}
+
+// GetLatestVersion implements client.RepoClient.
+func (f *FakeClient) GetLatestVersion(repoURL string, pkgName string) (string, error) {
+	if versions, ok := f.Packages[pkgName]; ok {
+		for v := range versions {
+			return v, nil
+		}
+	}
+	return "", errors.New("not found")
+}
+
+// GetPackageManifestURL implements client.RepoClient.
+func (f *FakeClient) GetPackageManifestURL(repoURL string, name string, version string) (string, error) {
+	return "fake url", nil
+}
diff --git a/internal/repo/client/repoclient.go b/internal/repo/client/repoclient.go
new file mode 100644
index 0000000..cbe833f
--- /dev/null
+++ b/internal/repo/client/repoclient.go
@@ -0,0 +1,15 @@
+package client
+
+import (
+	packagesv1alpha1 "github.com/glasskube/glasskube/api/v1alpha1"
+	"github.com/glasskube/glasskube/internal/repo/types"
+)
+
+type RepoClient interface {
+	FetchLatestPackageManifest(repoURL, name string, target *packagesv1alpha1.PackageManifest) (version string, err error)
+	FetchPackageManifest(repoURL, name, version string, target *packagesv1alpha1.PackageManifest) error
+	FetchPackageIndex(repoURL, name string, target *types.PackageIndex) error
+	FetchPackageRepoIndex(repoURL string, target *types.PackageRepoIndex) error
+	GetLatestVersion(repoURL string, pkgName string) (string, error)
+	GetPackageManifestURL(repoURL, name, version string) (string, error)
+}
diff --git a/internal/repo/interface.go b/internal/repo/interface.go
new file mode 100644
index 0000000..07f8029
--- /dev/null
+++ b/internal/repo/interface.go
@@ -0,0 +1,23 @@
+package repo
+
+import (
+	"github.com/glasskube/glasskube/internal/repo/client"
+	"github.com/glasskube/glasskube/internal/repo/types"
+)
+
+type (
+	PackageIndex         = types.PackageIndex
+	PackageIndexItem     = types.PackageIndexItem
+	PackageRepoIndex     = types.PackageRepoIndex
+	PackageRepoIndexItem = types.PackageRepoIndexItem
+)
+
+var (
+	DefaultClient              = client.New("https://packages.dl.glasskube.dev/packages/")
+	FetchLatestPackageManifest = DefaultClient.FetchLatestPackageManifest
+	FetchPackageManifest       = DefaultClient.FetchPackageManifest
+	FetchPackageIndex          = DefaultClient.FetchPackageIndex
+	FetchPackageRepoIndex      = DefaultClient.FetchPackageRepoIndex
+	GetLatestVersion           = DefaultClient.GetLatestVersion
+	GetPackageManifestURL      = DefaultClient.GetPackageManifestURL
+)
diff --git a/internal/repo/repo.go b/internal/repo/repo.go
deleted file mode 100644
index 4957d8a..0000000
--- a/internal/repo/repo.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package repo
-
-import (
-	"fmt"
-	"net/http"
-	"net/url"
-	"sync"
-	"time"
-
-	packagesv1alpha1 "github.com/glasskube/glasskube/api/v1alpha1"
-	"github.com/glasskube/glasskube/internal/httperror"
-	"k8s.io/apimachinery/pkg/util/yaml"
-)
-
-var defaultRepositoryURL = "https://packages.dl.glasskube.dev/packages/"
-
-var idxMutex sync.Mutex
-var idxUpdate time.Time
-var packageRepoIndex PackageRepoIndex
-
-func UpdatePackageManifest(pi *packagesv1alpha1.PackageInfo) (err error) {
-	var manifest packagesv1alpha1.PackageManifest
-	var version string
-	if pi.Spec.Version != "" {
-		// PackageInfo has explicit version in Spec
-		version = pi.Spec.Version
-		if err = FetchPackageManifest(pi.Spec.RepositoryUrl, pi.Spec.Name, version, &manifest); err != nil {
-			return
-		}
-	} else {
-		version, err = FetchLatestPackageManifest(pi.Spec.RepositoryUrl, pi.Spec.Name, &manifest)
-		if err != nil {
-			return
-		}
-	}
-
-	pi.Status.Manifest = &manifest
-	pi.Status.Version = version
-	return nil
-}
-
-func FetchLatestPackageManifest(repoURL, name string, target *packagesv1alpha1.PackageManifest) (version string, err error) {
-	var versions PackageIndex
-	if err = FetchPackageIndex(repoURL, name, &versions); err != nil {
-		if !httperror.IsNotFound(err) {
-			return
-		}
-		// no versions.yaml file for package in repo. Try versionless manifest
-		version = ""
-	} else {
-		version = versions.LatestVersion
-	}
-	err = FetchPackageManifest(repoURL, name, version, target)
-	return
-}
-
-func FetchPackageManifest(repoURL, name, version string, target *packagesv1alpha1.PackageManifest) error {
-	if url, err := GetPackageManifestURL(repoURL, name, version); err != nil {
-		return err
-	} else {
-		return fetchYAMLOrJSON(url, target)
-	}
-}
-
-func FetchPackageIndex(repoURL, name string, target *PackageIndex) error {
-	if url, err := getPackageIndexURL(repoURL, name); err != nil {
-		return err
-	} else {
-		return fetchYAMLOrJSON(url, target)
-	}
-}
-
-func FetchPackageRepoIndex(repoURL string, target *PackageRepoIndex) error {
-	if url, err := getPackageRepoIndexURL(repoURL); err != nil {
-		return err
-	} else {
-		return fetchYAMLOrJSON(url, target)
-	}
-}
-
-func GetLatestVersion(repoURL string, pkgName string) (string, error) {
-	idxMutex.Lock()
-	defer idxMutex.Unlock()
-	if len(packageRepoIndex.Packages) == 0 || idxUpdate.Add(5*time.Minute).Before(time.Now()) {
-		if err := FetchPackageRepoIndex(repoURL, &packageRepoIndex); err != nil {
-			return "", err
-		}
-		idxUpdate = time.Now()
-	}
-	for _, pkg := range packageRepoIndex.Packages {
-		if pkg.Name == pkgName {
-			return pkg.LatestVersion, nil
-		}
-	}
-	return "", nil
-}
-
-func fetchYAMLOrJSON(url string, target any) error {
-	resp, err := http.Get(url)
-	if err != nil {
-		return err
-	}
-	defer func() { _ = resp.Body.Close() }()
-	if err = httperror.CheckResponse(resp); err != nil {
-		return fmt.Errorf("failed to fetch %v: %w", url, err)
-	}
-	return yaml.NewYAMLOrJSONDecoder(resp.Body, 4096).Decode(target)
-}
-
-func getPackageRepoIndexURL(repoURL string) (string, error) {
-	return url.JoinPath(getBaseURL(repoURL), "index.yaml")
-}
-
-func getPackageIndexURL(repoURL, name string) (string, error) {
-	return url.JoinPath(getBaseURL(repoURL), url.PathEscape(name), "versions.yaml")
-}
-
-func GetPackageManifestURL(repoURL, name, version string) (string, error) {
-	pathSegments := []string{url.PathEscape(name)}
-	if version != "" {
-		pathSegments = append(pathSegments, url.PathEscape(version))
-	}
-	pathSegments = append(pathSegments, "package.yaml")
-	return url.JoinPath(getBaseURL(repoURL), pathSegments...)
-}
-
-func getBaseURL(explicitRepositoryURL string) string {
-	if len(explicitRepositoryURL) > 0 {
-		return explicitRepositoryURL
-	} else {
-		return defaultRepositoryURL
-	}
-}
diff --git a/internal/repo/types.go b/internal/repo/types/types.go
similarity index 97%
rename from internal/repo/types.go
rename to internal/repo/types/types.go
index 6d596f3..6941c4d 100644
--- a/internal/repo/types.go
+++ b/internal/repo/types/types.go
@@ -1,4 +1,4 @@
-package repo
+package types
 
 type PackageIndex struct {
 	Versions      []PackageIndexItem `json:"versions" jsonschema:"required"`
diff --git a/internal/web/components/pkg_detail_btns/controller.go b/internal/web/components/pkg_detail_btns/controller.go
index 53f9ba9..3657139 100644
--- a/internal/web/components/pkg_detail_btns/controller.go
+++ b/internal/web/components/pkg_detail_btns/controller.go
@@ -36,7 +36,7 @@ func Render(w io.Writer, tmpl *template.Template, pkg *v1alpha1.Package, status
 		PackageName:     pkg.Name,
 		Status:          status,
 		Manifest:        manifest,
-		UpdateAvailable: latestVersion != "" && pkg.Spec.PackageInfo.Version != "" && pkg.Spec.PackageInfo.Version != latestVersion,
+		UpdateAvailable: latestVersion != "" && pkg.Spec.PackageInfo.Version != latestVersion,
 	})
 }
 
@@ -54,6 +54,6 @@ func ForPkgDetailBtns(
 		PackageName:     pkgName,
 		Status:          status,
 		Manifest:        manifest,
-		UpdateAvailable: pkg != nil && pkg.Spec.PackageInfo.Version != "" && pkg.Spec.PackageInfo.Version != latestVersion,
+		UpdateAvailable: pkg != nil && pkg.Spec.PackageInfo.Version != latestVersion,
 	}
 }
diff --git a/internal/web/components/pkg_overview_btn/controller.go b/internal/web/components/pkg_overview_btn/controller.go
index a65c1ce..6022e13 100644
--- a/internal/web/components/pkg_overview_btn/controller.go
+++ b/internal/web/components/pkg_overview_btn/controller.go
@@ -33,19 +33,18 @@ func Render(w io.Writer, tmpl *template.Template, pkg *v1alpha1.Package, status
 		PackageName:     pkg.Name,
 		Status:          status,
 		Manifest:        manifest,
-		UpdateAvailable: latestVersion != "" && pkg.Spec.PackageInfo.Version != "" && pkg.Spec.PackageInfo.Version != latestVersion,
+		UpdateAvailable: latestVersion != "" && pkg.Spec.PackageInfo.Version != latestVersion,
 	})
 }
 
 func ForPkgOverviewBtn(packageWithStatus *list.PackageWithStatus) *pkgOverviewBtnInput {
 	buttonId := getButtonId(packageWithStatus.Name)
 	return &pkgOverviewBtnInput{
-		ButtonId:    buttonId,
-		Swap:        "",
-		PackageName: packageWithStatus.Name,
-		Status:      packageWithStatus.Status,
-		Manifest:    packageWithStatus.InstalledManifest,
-		UpdateAvailable: packageWithStatus.Package != nil && packageWithStatus.Package.Spec.PackageInfo.Version != "" &&
-			packageWithStatus.Package.Spec.PackageInfo.Version != packageWithStatus.LatestVersion,
+		ButtonId:        buttonId,
+		Swap:            "",
+		PackageName:     packageWithStatus.Name,
+		Status:          packageWithStatus.Status,
+		Manifest:        packageWithStatus.InstalledManifest,
+		UpdateAvailable: packageWithStatus.Package != nil && packageWithStatus.Package.Spec.PackageInfo.Version != packageWithStatus.LatestVersion,
 	}
 }
diff --git a/internal/web/server.go b/internal/web/server.go
index 2783cfd..121d5ed 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -11,6 +11,7 @@ import (
 	"net"
 	"net/http"
 	"os"
+	"strings"
 	"syscall"
 
 	"github.com/glasskube/glasskube/pkg/update"
@@ -133,7 +134,6 @@ func (s *server) Start(ctx context.Context) error {
 	router.Handle("/packages", s.requireReady(s.packages))
 	router.Handle("/packages/install", s.requireReady(s.install))
 	router.Handle("/packages/install/modal", s.requireReady(s.installModal))
-	router.Handle("/packages/install/modal/versions", s.requireReady(s.installModalVersions))
 	router.Handle("/packages/update", s.requireReady(s.update))
 	router.Handle("/packages/update/modal", s.requireReady(s.updateModal))
 	router.Handle("/packages/uninstall", s.requireReady(s.uninstall))
@@ -181,9 +181,18 @@ func (s *server) Start(ctx context.Context) error {
 func (s *server) install(w http.ResponseWriter, r *http.Request) {
 	pkgName := r.FormValue("packageName")
 	selectedVersion := r.FormValue("selectedVersion")
+	enableAutoUpdateVal := r.FormValue("enableAutoUpdate")
+	if selectedVersion == "" {
+		var packageIndex repo.PackageIndex
+		if err := repo.FetchPackageIndex("", pkgName, &packageIndex); err != nil {
+			fmt.Fprintf(os.Stderr, "❗ Error: Could not fetch package metadata: %v\n", err)
+			return
+		}
+		selectedVersion = packageIndex.LatestVersion
+	}
 	err := install.NewInstaller(s.pkgClient).
 		WithStatusWriter(statuswriter.Stderr()).
-		Install(r.Context(), pkgName, selectedVersion)
+		Install(r.Context(), pkgName, selectedVersion, strings.ToLower(enableAutoUpdateVal) == "on")
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "An error occurred installing %v: \n%v\n", pkgName, err)
 	}
@@ -191,27 +200,15 @@ func (s *server) install(w http.ResponseWriter, r *http.Request) {
 
 func (s *server) installModal(w http.ResponseWriter, r *http.Request) {
 	pkgName := r.FormValue("packageName")
-	err := pkgInstallModalTmpl.Execute(w, &map[string]any{
-		"PackageName": pkgName,
-	})
-	checkTmplError(err, "pkgInstallModalTmpl")
-}
-
-func (s *server) installModalVersions(w http.ResponseWriter, r *http.Request) {
-	enableAutoUpdateVal := r.FormValue("enableAutoUpdate")
-	showVersions := enableAutoUpdateVal == ""
 	var idx repo.PackageIndex
-	if showVersions {
-		pkgName := r.FormValue("packageName")
-		if err := repo.FetchPackageIndex("", pkgName, &idx); err != nil {
-			fmt.Fprintf(os.Stderr, "An error occurred fetching versions of %v: %v\n", pkgName, err)
-		}
+	if err := repo.FetchPackageIndex("", pkgName, &idx); err != nil {
+		fmt.Fprintf(os.Stderr, "An error occurred fetching versions of %v: %v\n", pkgName, err)
 	}
-	err := pkgInstallModalVersionsTmpl.Execute(w, &map[string]any{
-		"ShowVersions": showVersions,
+	err := pkgInstallModalTmpl.Execute(w, &map[string]any{
+		"PackageName":  pkgName,
 		"PackageIndex": &idx,
 	})
-	checkTmplError(err, "pkgInstallModalVersionsTmpl")
+	checkTmplError(err, "pkgInstallModalTmpl")
 }
 
 func (s *server) updateModal(w http.ResponseWriter, r *http.Request) {
@@ -310,7 +307,7 @@ func (s *server) packages(w http.ResponseWriter, r *http.Request) {
 	}
 	updatesAvailable := false
 	for _, pkg := range packages {
-		if pkg.Package != nil && pkg.Package.Spec.PackageInfo.Version != "" && pkg.Package.Spec.PackageInfo.Version != pkg.LatestVersion {
+		if pkg.Package != nil && pkg.Package.Spec.PackageInfo.Version != pkg.LatestVersion {
 			updatesAvailable = true
 			break
 		}
@@ -325,12 +322,15 @@ func (s *server) packages(w http.ResponseWriter, r *http.Request) {
 
 func (s *server) packageDetail(w http.ResponseWriter, r *http.Request) {
 	pkgName := mux.Vars(r)["pkgName"]
-	pkg, status, manifest, err := describe.DescribePackage(r.Context(), pkgName)
+	pkg, status, manifest, latestVersion, err := describe.DescribePackage(r.Context(), pkgName)
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "An error occurred fetching package details of %v: \n%v\n", pkgName, err)
 		return
 	}
-	latestVersion, err := repo.GetLatestVersion("", pkgName)
+	if latestVersion == "" {
+		// TODO have a look at handling latestVersion as return value from DescribePackage again – seems weird
+		latestVersion, err = repo.GetLatestVersion("", pkgName)
+	}
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "An error occurred fetching latest version of %v: \n%v\n", pkgName, err)
 	}
diff --git a/internal/web/templates.go b/internal/web/templates.go
index 2525c36..b017550 100644
--- a/internal/web/templates.go
+++ b/internal/web/templates.go
@@ -13,20 +13,19 @@ import (
 )
 
 var (
-	baseTemplate                *template.Template
-	pkgsPageTmpl                *template.Template
-	pkgPageTmpl                 *template.Template
-	supportPageTmpl             *template.Template
-	bootstrapPageTmpl           *template.Template
-	kubeconfigPageTmpl          *template.Template
-	pkgOverviewBtnTmpl          *template.Template
-	pkgDetailBtnsTmpl           *template.Template
-	pkgInstallModalTmpl         *template.Template
-	pkgInstallModalVersionsTmpl *template.Template
-	pkgUpdateModalTmpl          *template.Template
-	templatesDir                = "templates"
-	componentsDir               = path.Join(templatesDir, "components")
-	pagesDir                    = path.Join(templatesDir, "pages")
+	baseTemplate        *template.Template
+	pkgsPageTmpl        *template.Template
+	pkgPageTmpl         *template.Template
+	supportPageTmpl     *template.Template
+	bootstrapPageTmpl   *template.Template
+	kubeconfigPageTmpl  *template.Template
+	pkgOverviewBtnTmpl  *template.Template
+	pkgDetailBtnsTmpl   *template.Template
+	pkgInstallModalTmpl *template.Template
+	pkgUpdateModalTmpl  *template.Template
+	templatesDir        = "templates"
+	componentsDir       = path.Join(templatesDir, "components")
+	pagesDir            = path.Join(templatesDir, "pages")
 )
 
 func init() {
@@ -52,7 +51,6 @@ func init() {
 	pkgOverviewBtnTmpl = componentTmpl(pkg_overview_btn.TemplateId, "pkg-overview-btn.html")
 	pkgDetailBtnsTmpl = componentTmpl(pkg_detail_btns.TemplateId, "pkg-detail-btns.html")
 	pkgInstallModalTmpl = componentTmpl("pkg-install-modal", "pkg-install-modal.html")
-	pkgInstallModalVersionsTmpl = componentTmpl("pkg-install-modal-versions", "pkg-install-modal-versions.html")
 	pkgUpdateModalTmpl = componentTmpl("pkg-update-modal", "pkg-update-modal.html")
 }
 
diff --git a/internal/web/templates/components/pkg-install-modal-versions.html b/internal/web/templates/components/pkg-install-modal-versions.html
deleted file mode 100644
index 835740e..0000000
--- a/internal/web/templates/components/pkg-install-modal-versions.html
+++ /dev/null
@@ -1,18 +0,0 @@
-{{ define "pkg-install-modal-versions" }}
-  {{ if .ShowVersions }}
-    {{ with $idx := .PackageIndex }}
-      <div class="input-group mt-2 mb-1">
-        <label class="input-group-text" for="pkg-install-modal-version">Version</label>
-        <select class="form-select" id="pkg-install-modal-version" name="selectedVersion" required>
-          {{ range $idx.Versions }}
-            <option value="{{ .Version }}" {{ if eq .Version $idx.LatestVersion }}selected{{ end }}>
-              {{ .Version }}
-              {{ if eq .Version $idx.LatestVersion }}(latest){{ end }}
-            </option>
-          {{ end }}
-        </select>
-      </div>
-    {{ end }}
-  {{ end }}
-
-{{ end }}
diff --git a/internal/web/templates/components/pkg-install-modal.html b/internal/web/templates/components/pkg-install-modal.html
index 476a77e..0f5318b 100644
--- a/internal/web/templates/components/pkg-install-modal.html
+++ b/internal/web/templates/components/pkg-install-modal.html
@@ -10,22 +10,30 @@
           <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
         </div>
         <div class="modal-body" id="pkg-install-modal-body">
-          <div class="form-switch">
+          <div id="pkg-install-modal-versions">
+            {{ with $idx := .PackageIndex }}
+              <div class="input-group mt-1">
+                <label class="input-group-text" for="pkg-install-modal-version">Version</label>
+                <select class="form-select" id="pkg-install-modal-version" name="selectedVersion" required>
+                  {{ range $idx.Versions }}
+                    <option value="{{ .Version }}" {{ if eq .Version $idx.LatestVersion }}selected{{ end }}>
+                      {{ .Version }}
+                      {{ if eq .Version $idx.LatestVersion }}(latest){{ end }}
+                    </option>
+                  {{ end }}
+                </select>
+              </div>
+            {{ end }}
+          </div>
+          <div class="form-switch mt-2 mb-1">
             <input
               class="form-check-input"
               role="switch"
               type="checkbox"
               name="enableAutoUpdate"
-              id="enable-auto-update"
-              checked
-              hx-get="/packages/install/modal/versions?packageName={{ .PackageName }}"
-              hx-target="#pkg-install-modal-versions"
-              hx-swap="innerHTML" />
-            <label class="form-check-label ms-1" for="enable-auto-update">
-              Install Latest Version and Update Automatically
-            </label>
+              id="enable-auto-update" />
+            <label class="form-check-label ms-1" for="enable-auto-update">Enable Auto Updates</label>
           </div>
-          <div id="pkg-install-modal-versions"></div>
         </div>
         <div class="modal-footer">
           <button type="button" class="btn btn-outline-primary btn-sm" data-bs-dismiss="modal">Cancel</button>
diff --git a/internal/web/templates/components/pkg-update-modal.html b/internal/web/templates/components/pkg-update-modal.html
index 30cf2fc..43917e4 100644
--- a/internal/web/templates/components/pkg-update-modal.html
+++ b/internal/web/templates/components/pkg-update-modal.html
@@ -1,7 +1,7 @@
 {{ define "pkg-update-modal" }}
   <div class="modal-dialog modal-dialog-centered" id="pkg-update-modal">
     <div class="modal-content">
-      <form hx-post="/packages/update" hx-target="#packages-update-warning" hx-swap="outerHTML">
+      <form hx-post="/packages/update" hx-swap="none">
         <div class="modal-header">
           <h1 class="modal-title fs-5" id="exampleModalLabel">Available Updates</h1>
           <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
diff --git a/internal/web/templates/pages/packages.html b/internal/web/templates/pages/packages.html
index 5f1f54a..9f71fed 100644
--- a/internal/web/templates/pages/packages.html
+++ b/internal/web/templates/pages/packages.html
@@ -7,7 +7,7 @@
     hx-target="main">
     {{ if .UpdatesAvailable }}
       <div
-        class="alert alert-warning py-1 ps-2 pe-1 d-flex flex-row align-items-center"
+        class="visually-hidden alert alert-warning py-1 ps-2 pe-1 d-flex flex-row align-items-center"
         role="alert"
         id="packages-update-warning">
         <span class="flex-grow-1">Updates for your packages are available!</span>
diff --git a/internal/webhook/errors.go b/internal/webhook/errors.go
new file mode 100644
index 0000000..008340f
--- /dev/null
+++ b/internal/webhook/errors.go
@@ -0,0 +1,30 @@
+package webhook
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/glasskube/glasskube/api/v1alpha1"
+	"github.com/glasskube/glasskube/internal/dependency"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+var ErrInvalidObject = errors.New("validator called with unexpected object type")
+var ErrDependencyConflict = errors.New("dependency conflict")
+var ErrTransitiveDependency = errors.New("support for transitive dependencies is not implemented yet")
+
+func newConflictError(conflicts dependency.Conflicts) error {
+	return fmt.Errorf("%w: %v", ErrDependencyConflict, conflicts)
+}
+
+func newConflictErrorDelete(owner metav1.OwnerReference) error {
+	return fmt.Errorf("%w: dependency of %v", ErrDependencyConflict, owner.Name)
+}
+
+func newTransitiveError(requirement dependency.PackageWithVersion, dependency v1alpha1.Dependency) error {
+	return fmt.Errorf("%w: requirement %v (%v) depends on %v (%v)",
+		ErrTransitiveDependency, requirement.Name, requirement.Version, dependency.Name, dependency.Version)
+}
+
+func isErrDependencyConflict(err error) bool   { return errors.Is(err, ErrDependencyConflict) }
+func isErrTransitiveDependency(err error) bool { return errors.Is(err, ErrTransitiveDependency) }
diff --git a/internal/webhook/package_validator.go b/internal/webhook/package_validator.go
new file mode 100644
index 0000000..48584e4
--- /dev/null
+++ b/internal/webhook/package_validator.go
@@ -0,0 +1,129 @@
+package webhook
+
+import (
+	"context"
+
+	"github.com/glasskube/glasskube/api/v1alpha1"
+	"github.com/glasskube/glasskube/internal/controller/owners"
+	"github.com/glasskube/glasskube/internal/dependency"
+	ctrladapter "github.com/glasskube/glasskube/internal/dependency/adapter/controllerruntime"
+	"github.com/glasskube/glasskube/internal/repo"
+	repoclient "github.com/glasskube/glasskube/internal/repo/client"
+	"go.uber.org/multierr"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/types"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+)
+
+type PackageValidatingWebhook struct {
+	client.Client
+	*owners.OwnerManager
+	*dependency.DependendcyManager
+	repo repoclient.RepoClient
+}
+
+//+kubebuilder:webhook:path=/validate-packages-glasskube-dev-v1alpha1-package,mutating=false,failurePolicy=fail,sideEffects=None,groups=packages.glasskube.dev,resources=packages,verbs=create;update;delete,versions=v1alpha1,name=vpackage.kb.io,admissionReviewVersions=v1
+
+var _ webhook.CustomValidator = &PackageValidatingWebhook{}
+
+func (p *PackageValidatingWebhook) SetupWithManager(mgr ctrl.Manager) error {
+	p.OwnerManager = owners.NewOwnerManager(p.Scheme())
+	p.DependendcyManager = dependency.NewDependencyManager(ctrladapter.NewControllerRuntimeAdapter(p.Client))
+	p.repo = repo.DefaultClient
+	return ctrl.NewWebhookManagedBy(mgr).
+		For(&v1alpha1.Package{}).
+		WithValidator(p).
+		Complete()
+}
+
+// ValidateCreate implements admission.CustomValidator.
+func (p *PackageValidatingWebhook) ValidateCreate(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) {
+	log := ctrl.LoggerFrom(ctx)
+	if pkg, ok := obj.(*v1alpha1.Package); ok {
+		log.Info("validate create", "name", pkg.Name)
+		return nil, p.validateCreateOrUpdate(ctx, pkg)
+	}
+	return nil, ErrInvalidObject
+}
+
+// ValidateUpdate implements admission.CustomValidator.
+func (p *PackageValidatingWebhook) ValidateUpdate(ctx context.Context, oldObj runtime.Object, newObj runtime.Object) (warnings admission.Warnings, err error) {
+	log := ctrl.LoggerFrom(ctx)
+	if oldPkg, ok := oldObj.(*v1alpha1.Package); ok {
+		if newPkg, ok := newObj.(*v1alpha1.Package); ok {
+			if oldPkg.Spec.PackageInfo == newPkg.Spec.PackageInfo {
+				// If the package info did not change, we are already done
+				return nil, nil
+			} else {
+				log.Info("validate update", "name", newPkg.Name)
+				return nil, p.validateCreateOrUpdate(ctx, newPkg)
+			}
+		}
+	}
+	return nil, ErrInvalidObject
+}
+
+// ValidateDelete implements admission.CustomValidator.
+//
+// A package can not be deleted if another package depends on it. This is the case if it has owner references
+// to at least one other package. However, we do have to allow packages to be deleted if all their dependant
+// packages are already being deleted, because the webhook is also called if a package is garbage-collected and
+// this would lead to a dead lock otherwise:
+// The dependant package is not deleted because of the foreground deletion finalizer and the dependency is not
+// deleted because the dependant is not deleted.
+func (p *PackageValidatingWebhook) ValidateDelete(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) {
+	log := ctrl.LoggerFrom(ctx)
+	if pkg, ok := obj.(*v1alpha1.Package); ok {
+		log.Info("validate delete", "name", pkg.Name)
+		if dependants, err := p.OwnersOfType(&v1alpha1.Package{}, pkg); err != nil {
+			return nil, err
+		} else if len(dependants) > 0 {
+			for _, dep := range dependants {
+				var depPkg v1alpha1.Package
+				if err := p.Get(ctx, types.NamespacedName{Name: dep.Name}, &depPkg); err != nil {
+					return nil, err
+				} else if depPkg.DeletionTimestamp.IsZero() {
+					return nil, newConflictErrorDelete(dependants[0])
+				}
+			}
+		}
+		return nil, nil
+	}
+	return nil, ErrInvalidObject
+}
+
+func (p *PackageValidatingWebhook) validateCreateOrUpdate(ctx context.Context, pkg *v1alpha1.Package) error {
+	// We must expect that this package is not installed in this version, so the PackageInfo does not exist.
+	var manifest v1alpha1.PackageManifest
+	err := p.repo.FetchPackageManifest("", pkg.Spec.PackageInfo.Name, pkg.Spec.PackageInfo.Version, &manifest)
+	if err != nil {
+		return err
+	}
+
+	if result, err := p.Validate(ctx, pkg, &manifest); err != nil {
+		return err
+	} else if len(result.Conflicts) > 0 {
+		// Conflicts are not allowed.
+		return newConflictError(result.Conflicts)
+	} else if len(result.Requirements) > 0 {
+		// Transitive dependencies are not supported yet, so we validate that a required package does not have any
+		// dependencies itself.
+		// TODO: Add support for validating transitive dependencies.
+		var errs error
+		for _, req := range result.Requirements {
+			var depManifest v1alpha1.PackageManifest
+			err := p.repo.FetchPackageManifest("", req.Name, req.Version, &depManifest)
+			if err != nil {
+				errs = multierr.Append(errs, err)
+			} else if len(depManifest.Dependencies) > 0 {
+				errs = multierr.Append(errs, newTransitiveError(req, depManifest.Dependencies[0]))
+			}
+		}
+		return errs
+	} else {
+		return nil
+	}
+}
diff --git a/pkg/describe/describe.go b/pkg/describe/describe.go
index f6a330c..565acf6 100644
--- a/pkg/describe/describe.go
+++ b/pkg/describe/describe.go
@@ -14,7 +14,7 @@ import (
 func DescribePackage(
 	ctx context.Context,
 	pkgName string,
-) (*v1alpha1.Package, *client.PackageStatus, *v1alpha1.PackageManifest, error) {
+) (*v1alpha1.Package, *client.PackageStatus, *v1alpha1.PackageManifest, string, error) {
 	pkgClient := client.FromContext(ctx)
 	var pkg v1alpha1.Package
 	var status *client.PackageStatus
@@ -22,18 +22,19 @@ func DescribePackage(
 		// pkg installed: try to use installed manifest
 		status = client.GetStatusOrPending(&pkg.Status)
 		if installedManifest, err := manifest.GetInstalledManifestForPackage(ctx, pkg); err == nil {
-			return &pkg, status, installedManifest, nil
+			return &pkg, status, installedManifest, "", nil
 		} else if !(errors.Is(err, manifest.ErrPackageNoManifest) || apierrors.IsNotFound(err)) {
-			return nil, nil, nil, err
+			return nil, nil, nil, "", err
 		}
 	} else if !apierrors.IsNotFound(err) {
-		return nil, nil, nil, err
+		return nil, nil, nil, "", err
 	}
 
 	// pkg not installed or no manifest found: use manifest from repo
 	var packageManifest v1alpha1.PackageManifest
-	if _, err := repo.FetchLatestPackageManifest("", pkgName, &packageManifest); err != nil {
-		return nil, nil, nil, err
+	if latestVersion, err := repo.FetchLatestPackageManifest("", pkgName, &packageManifest); err != nil {
+		return nil, nil, nil, "", err
+	} else {
+		return &pkg, status, &packageManifest, latestVersion, nil
 	}
-	return &pkg, status, &packageManifest, nil
 }
diff --git a/pkg/install/install.go b/pkg/install/install.go
index ea22529..c4add79 100644
--- a/pkg/install/install.go
+++ b/pkg/install/install.go
@@ -28,11 +28,15 @@ func (obj *installer) WithStatusWriter(sw statuswriter.StatusWriter) *installer
 
 // InstallBlocking creates a new v1alpha1.Package custom resource in the cluster and waits until
 // the package has either status Ready or Failed.
-// An empty version is allowed and is interpreted as "auto-updates enabled".
-func (obj *installer) InstallBlocking(ctx context.Context, packageName, version string) (*client.PackageStatus, error) {
+func (obj *installer) InstallBlocking(
+	ctx context.Context,
+	packageName,
+	version string,
+	autoUpdates bool,
+) (*client.PackageStatus, error) {
 	obj.status.Start()
 	defer obj.status.Stop()
-	pkg, err := obj.install(ctx, packageName, version)
+	pkg, err := obj.install(ctx, packageName, version, autoUpdates)
 	if err != nil {
 		return nil, err
 	}
@@ -40,17 +44,26 @@ func (obj *installer) InstallBlocking(ctx context.Context, packageName, version
 }
 
 // Install creates a new v1alpha1.Package custom resource in the cluster.
-// An empty version is allowed and is interpreted as "auto-updates enabled".
-func (obj *installer) Install(ctx context.Context, packageName, version string) error {
+func (obj *installer) Install(ctx context.Context, packageName, version string, autoUpdates bool) error {
 	obj.status.Start()
 	defer obj.status.Stop()
-	_, err := obj.install(ctx, packageName, version)
+	_, err := obj.install(ctx, packageName, version, autoUpdates)
 	return err
 }
 
-func (obj *installer) install(ctx context.Context, packageName, version string) (*v1alpha1.Package, error) {
+func (obj *installer) install(
+	ctx context.Context,
+	packageName,
+	version string,
+	autoUpdates bool,
+) (*v1alpha1.Package, error) {
 	obj.status.SetStatus(fmt.Sprintf("Installing %v...", packageName))
 	pkg := client.NewPackage(packageName, version)
+	if autoUpdates {
+		pkg.SetLabels(map[string]string{
+			"packages.glasskube.dev/auto-update": "true",
+		})
+	}
 	err := obj.client.Packages().Create(ctx, pkg)
 	if err != nil {
 		return nil, err
diff --git a/pkg/list/list.go b/pkg/list/list.go
index 6ae1a1e..29a9b24 100644
--- a/pkg/list/list.go
+++ b/pkg/list/list.go
@@ -69,7 +69,6 @@ func (item listResultTuple) Installed() bool {
 
 func (item listResultTuple) Outdated() bool {
 	return item.Package != nil && item.IndexItem != nil &&
-		item.Package.Spec.PackageInfo.Version != "" &&
 		item.Package.Spec.PackageInfo.Version != item.IndexItem.LatestVersion
 }
 
diff --git a/pkg/update/update.go b/pkg/update/update.go
index 0e1187b..c549f63 100644
--- a/pkg/update/update.go
+++ b/pkg/update/update.go
@@ -86,7 +86,7 @@ outer:
 	for _, pkg := range packagesToUpdate {
 		for _, indexItem := range index.Packages {
 			if indexItem.Name == pkg.Name {
-				if pkg.Spec.PackageInfo.Version != "" && pkg.Spec.PackageInfo.Version != indexItem.LatestVersion {
+				if pkg.Spec.PackageInfo.Version != indexItem.LatestVersion {
 					// this package should be updated
 					tx.Items = append(tx.Items, updateTransactionItem{
 						Package: pkg,
diff --git a/release.Dockerfile b/release.Dockerfile
index 3f1ba8e..76d4e8e 100644
--- a/release.Dockerfile
+++ b/release.Dockerfile
@@ -2,6 +2,7 @@
 # Refer to https://github.com/GoogleContainerTools/distroless for more details
 FROM gcr.io/distroless/static:nonroot
 WORKDIR /
-COPY package-operator /package-operator 
+COPY package-operator /package-operator
+COPY cert-manager /cert-manager
 USER 65532:65532
 ENTRYPOINT ["/package-operator"]
diff --git a/website/docs/03_components/02_package-operator.md b/website/docs/03_components/02_package-operator.md
index e6340b1..0f2b80c 100644
--- a/website/docs/03_components/02_package-operator.md
+++ b/website/docs/03_components/02_package-operator.md
@@ -14,11 +14,9 @@ The PackageInfo controller syncs the relevant `PackageInfo` resources with the m
 
 ## Handling Package Updates
 
-A Package may have it's `.spec.version` set. 
+A Package must have it's `.spec.version` set. 
 This instructs the operator to install this exact version of the package. 
 We also call this version pinning.
-A Package's `.spec.version` may also be empty. 
-In that case the operator will try to automatically update the package to it's latest version.
 
 To update a package with a pinned version, run `glasskube update <package>`. 
 This will upate the package to the latest version. 
@@ -29,19 +27,14 @@ title: Package Reconciliation
 ---
 flowchart TB
   start((Start))
-  pinned{.spec.version present}
   ensurePinned[Create PackageInfo for .spec.version if missing]
-  ensureLatest[Create PackageInfo for latest version if missing]
   ready{PackageInfo is ready}
   ensureManifest[Ensure manifest]
   ensureManifestOk{Success}
   cleanup[Clean up old PackageInfos]
   end1([End])
 
-  start --> pinned
-  pinned -- yes --> ensurePinned
-  pinned -- no --> ensureLatest
-  ensureLatest --> ready
+  start --> ensurePinned
   ensurePinned --> ready
   ready -- yes --> ensureManifest
   ready -- no --> end1
@@ -57,27 +50,19 @@ title: PackageInfo Reconciliation
 ---
 flowchart TB
   start((Start))
-  pinned{.spec.version present}
-  noVersion{Versionless package?}
-  DP[Fetch package.yaml]
-  DV[Fetch versions.yaml]
   DVP[Fetch version/package.yaml]
   end1([End])
 
-  start --> pinned
-  pinned -- no --> noVersion
-  noVersion -- yes --> DP
-  noVersion -- no --> DV
-  pinned -- yes --> DVP
-  DV -- if ok --> DVP
-  DP --> end1
+  start --> DVP
   DVP --> end1
 ```
 
 ### FAQ
 **How is the latest version determined?**
 
-The package's `versions.yaml` is fetched from the repository. This file contains all available versions.
+The package's `versions.yaml` is fetched from the repository. This file contains all available versions and a field `latestVersion`. 
+Note that `latestVersion` might not be equal to the actual latest available version – think of a package where `latestVersion = v1.1.7`, 
+while there might already be a prerelease of a new major version like `v2.0.0-alpha.1`. 
 
 **How is a specific version of a package fetched?**
 
diff --git a/website/src/partials/_install.mdx b/website/src/partials/_install.mdx
index 916d1bc..6284592 100644
--- a/website/src/partials/_install.mdx
+++ b/website/src/partials/_install.mdx
@@ -15,7 +15,7 @@ import TabItem from '@theme/TabItem';
 
       {/* x-release-please-start-version */}
       ```
-      dnf install https://releases.dl.glasskube.dev/glasskube_v0.0.3_amd64.rpm
+      dnf install https://releases.dl.glasskube.dev/glasskube_v0.0.4_amd64.rpm
       ```
       {/* x-release-please-end */}
     </details>
@@ -25,8 +25,8 @@ import TabItem from '@theme/TabItem';
 
       {/* x-release-please-start-version */}
       ```
-      curl -LO https://releases.dl.glasskube.dev/glasskube_v0.0.3_amd64.deb
-      sudo dpkg -i glasskube_v0.0.3_amd64.deb
+      curl -LO https://releases.dl.glasskube.dev/glasskube_v0.0.4_amd64.deb
+      sudo dpkg -i glasskube_v0.0.4_amd64.deb
       ```
       {/* x-release-please-end */}
     </details>
@@ -35,7 +35,7 @@ import TabItem from '@theme/TabItem';
       <summary>APK-based installation (Alpine)</summary>
       {/* x-release-please-start-version */}
       ```
-      curl -LO https://releases.dl.glasskube.dev/glasskube_v0.0.3_amd64.apk
+      curl -LO https://releases.dl.glasskube.dev/glasskube_v0.0.4_amd64.apk
       apk add glasskube.apk
       ```
       {/* x-release-please-end */}
@@ -47,7 +47,7 @@ import TabItem from '@theme/TabItem';
 
   <TabItem value="win" label="Windows">
     {/* x-release-please-start-version */}
-    Download the [windows archive](https://releases.dl.glasskube.dev/glasskube_v0.0.3_windows_x86_64.zip) from our
+    Download the [windows archive](https://releases.dl.glasskube.dev/glasskube_v0.0.4_windows_x86_64.zip) from our
     latest [Release](https://github.com/glasskube/glasskube/releases/latest) and unpack it using Windows Explorer.
     {/* x-release-please-end */}
   </TabItem>
diff --git a/website/static/release.json b/website/static/release.json
index da0ce10..f59ee70 100644
--- a/website/static/release.json
+++ b/website/static/release.json
@@ -1,1 +1,1 @@
-{"version":"0.0.3"}
+{"version":"0.0.4"}


Test patch:
diff --git a/internal/dependency/manager_test.go b/internal/dependency/manager_test.go
index 17e7f50..97a793c 100644
--- a/internal/dependency/manager_test.go
+++ b/internal/dependency/manager_test.go
@@ -107,7 +107,7 @@ var _ = Describe("Dependency Manager", func() {
 
 		When("P has no dependencies", func() {
 			It("should return OK", func(ctx context.Context) {
-				res, err := dm.Validate(ctx, p, pi)
+				res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 				Expect(err).ShouldNot(HaveOccurred())
 				Expect(res).ShouldNot(BeNil())
 				Expect(res.Status).Should(Equal(ValidationResultStatusOk))
@@ -134,7 +134,7 @@ var _ = Describe("Dependency Manager", func() {
 
 					When("no other package dependent on D", func() {
 						It("should return OK", func(ctx context.Context) {
-							res, err := dm.Validate(ctx, p, pi)
+							res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 							Expect(err).ShouldNot(HaveOccurred())
 							Expect(res).ShouldNot(BeNil())
 							Expect(res.Status).Should(Equal(ValidationResultStatusOk))
@@ -158,7 +158,7 @@ var _ = Describe("Dependency Manager", func() {
 
 						When("X and Y require no version range of D", func() {
 							It("should return OK", func(ctx context.Context) {
-								res, err := dm.Validate(ctx, p, pi)
+								res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 								Expect(err).ShouldNot(HaveOccurred())
 								Expect(res).ShouldNot(BeNil())
 								Expect(res.Status).Should(Equal(ValidationResultStatusOk))
@@ -170,7 +170,7 @@ var _ = Describe("Dependency Manager", func() {
 						When("X requires D in version range", func() {
 							It("should return OK", func(ctx context.Context) {
 								xi.Status.Manifest.Dependencies[0].Version = ">= 1, < 2"
-								res, err := dm.Validate(ctx, p, pi)
+								res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 								Expect(err).ShouldNot(HaveOccurred())
 								Expect(res).ShouldNot(BeNil())
 								Expect(res.Status).Should(Equal(ValidationResultStatusOk))
@@ -182,7 +182,7 @@ var _ = Describe("Dependency Manager", func() {
 						When("Y requires D in version range", func() {
 							It("should return OK", func(ctx context.Context) {
 								yi.Status.Manifest.Dependencies[0].Version = "1.x.x"
-								res, err := dm.Validate(ctx, p, pi)
+								res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 								Expect(err).ShouldNot(HaveOccurred())
 								Expect(res).ShouldNot(BeNil())
 								Expect(res.Status).Should(Equal(ValidationResultStatusOk))
@@ -195,7 +195,7 @@ var _ = Describe("Dependency Manager", func() {
 							It("should return OK", func(ctx context.Context) {
 								xi.Status.Manifest.Dependencies[0].Version = ">= 1, < 2"
 								yi.Status.Manifest.Dependencies[0].Version = "1.x.x"
-								res, err := dm.Validate(ctx, p, pi)
+								res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 								Expect(err).ShouldNot(HaveOccurred())
 								Expect(res).ShouldNot(BeNil())
 								Expect(res.Status).Should(Equal(ValidationResultStatusOk))
@@ -210,7 +210,7 @@ var _ = Describe("Dependency Manager", func() {
 
 					It("should return a RESOLVABLE result with D in latest", func(ctx context.Context) {
 						latestVersion = "1.1.7"
-						res, err := dm.Validate(ctx, p, pi)
+						res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 						Expect(err).ShouldNot(HaveOccurred())
 						Expect(res).ShouldNot(BeNil())
 						Expect(res.Status).Should(Equal(ValidationResultStatusResolvable))
@@ -242,7 +242,7 @@ var _ = Describe("Dependency Manager", func() {
 
 							It("should return OK if D's version is in required range", func(ctx context.Context) {
 								d, di = createPackageAndInfo("D", "1.3.0")
-								res, err := dm.Validate(ctx, p, pi)
+								res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 								Expect(err).ShouldNot(HaveOccurred())
 								Expect(res).ShouldNot(BeNil())
 								Expect(res.Status).Should(Equal(ValidationResultStatusOk))
@@ -252,7 +252,7 @@ var _ = Describe("Dependency Manager", func() {
 
 							It("should return CONFLICT if D's version is too old", func(ctx context.Context) {
 								d, di = createPackageAndInfo("D", "1.2.1")
-								res, err := dm.Validate(ctx, p, pi)
+								res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 								Expect(err).ShouldNot(HaveOccurred())
 								Expect(res).ShouldNot(BeNil())
 								Expect(res.Status).Should(Equal(ValidationResultStatusConflict))
@@ -267,7 +267,7 @@ var _ = Describe("Dependency Manager", func() {
 
 							It("should return CONFLICT if D's version is too new", func(ctx context.Context) {
 								d, di = createPackageAndInfo("D", "2.0.0-alpha.2")
-								res, err := dm.Validate(ctx, p, pi)
+								res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 								Expect(err).ShouldNot(HaveOccurred())
 								Expect(res).ShouldNot(BeNil())
 								Expect(res.Status).Should(Equal(ValidationResultStatusConflict))
@@ -298,7 +298,7 @@ var _ = Describe("Dependency Manager", func() {
 
 							It("should return OK if D's version is in required range", func(ctx context.Context) {
 								d, di = createPackageAndInfo("D", "1.3.1")
-								res, err := dm.Validate(ctx, p, pi)
+								res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 								Expect(err).ShouldNot(HaveOccurred())
 								Expect(res).ShouldNot(BeNil())
 								Expect(res.Status).Should(Equal(ValidationResultStatusOk))
@@ -308,7 +308,7 @@ var _ = Describe("Dependency Manager", func() {
 
 							It("should return CONFLICT if D's version is too old", func(ctx context.Context) {
 								d, di = createPackageAndInfo("D", "1.1.7")
-								res, err := dm.Validate(ctx, p, pi)
+								res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 								Expect(err).ShouldNot(HaveOccurred())
 								Expect(res).ShouldNot(BeNil())
 								Expect(res.Status).Should(Equal(ValidationResultStatusConflict))
@@ -323,7 +323,7 @@ var _ = Describe("Dependency Manager", func() {
 
 							It("should return CONFLICT if D's version is too new", func(ctx context.Context) {
 								d, di = createPackageAndInfo("D", "2.0.0-alpha.2")
-								res, err := dm.Validate(ctx, p, pi)
+								res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 								Expect(err).ShouldNot(HaveOccurred())
 								Expect(res).ShouldNot(BeNil())
 								Expect(res.Status).Should(Equal(ValidationResultStatusConflict))
@@ -355,7 +355,7 @@ var _ = Describe("Dependency Manager", func() {
 
 						It("should return OK if D's version is in required range", func(ctx context.Context) {
 							d, di = createPackageAndInfo("D", "1.4.0")
-							res, err := dm.Validate(ctx, p, pi)
+							res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 							Expect(err).ShouldNot(HaveOccurred())
 							Expect(res).ShouldNot(BeNil())
 							Expect(res.Status).Should(Equal(ValidationResultStatusOk))
@@ -365,7 +365,7 @@ var _ = Describe("Dependency Manager", func() {
 
 						It("should return CONFLICT if D's version is too old", func(ctx context.Context) {
 							d, di = createPackageAndInfo("D", "1.2.1")
-							res, err := dm.Validate(ctx, p, pi)
+							res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 							Expect(err).ShouldNot(HaveOccurred())
 							Expect(res).ShouldNot(BeNil())
 							Expect(res.Status).Should(Equal(ValidationResultStatusConflict))
@@ -380,7 +380,7 @@ var _ = Describe("Dependency Manager", func() {
 
 						It("should return CONFLICT if D's version is too new", func(ctx context.Context) {
 							d, di = createPackageAndInfo("D", "2.0.0")
-							res, err := dm.Validate(ctx, p, pi)
+							res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 							Expect(err).ShouldNot(HaveOccurred())
 							Expect(res).ShouldNot(BeNil())
 							Expect(res.Status).Should(Equal(ValidationResultStatusConflict))
@@ -399,7 +399,7 @@ var _ = Describe("Dependency Manager", func() {
 
 					It("should return a RESOLVABLE result with latest D", func(ctx context.Context) {
 						latestVersion = "1.7.4"
-						res, err := dm.Validate(ctx, p, pi)
+						res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 						Expect(err).ShouldNot(HaveOccurred())
 						Expect(res).ShouldNot(BeNil())
 						Expect(res.Status).Should(Equal(ValidationResultStatusResolvable))
@@ -431,7 +431,7 @@ var _ = Describe("Dependency Manager", func() {
 					It("Should return OK", func(ctx context.Context) {
 						d, di = createPackageAndInfo("D", "118.0.0")
 						e, ei = createPackageAndInfo("E", "11.80.0")
-						res, err := dm.Validate(ctx, p, pi)
+						res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 						Expect(err).ShouldNot(HaveOccurred())
 						Expect(res).ShouldNot(BeNil())
 						Expect(res.Status).Should(Equal(ValidationResultStatusOk))
@@ -443,7 +443,7 @@ var _ = Describe("Dependency Manager", func() {
 				When("D, E do not exist", func() {
 					It("Should return RESOLVABLE result with D, E as requirements", func(ctx context.Context) {
 						latestVersion = "1.1.7"
-						res, err := dm.Validate(ctx, p, pi)
+						res, err := dm.Validate(ctx, p, pi.Status.Manifest)
 						Expect(err).ShouldNot(HaveOccurred())
 						Expect(res).ShouldNot(BeNil())
 						Expect(res.Status).Should(Equal(ValidationResultStatusResolvable))
diff --git a/internal/webhook/package_validator_test.go b/internal/webhook/package_validator_test.go
new file mode 100644
index 0000000..f9f9960
--- /dev/null
+++ b/internal/webhook/package_validator_test.go
@@ -0,0 +1,208 @@
+package webhook
+
+import (
+	"context"
+	"time"
+
+	"github.com/glasskube/glasskube/api/v1alpha1"
+	"github.com/glasskube/glasskube/internal/controller/owners"
+	"github.com/glasskube/glasskube/internal/dependency"
+	ctrladapter "github.com/glasskube/glasskube/internal/dependency/adapter/controllerruntime"
+	fakerepo "github.com/glasskube/glasskube/internal/repo/client/fake"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/client-go/kubernetes/scheme"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+)
+
+var fakeRepo = fakerepo.FakeClient{}
+
+func newPackageValidatingWebhook(objects ...client.Object) *PackageValidatingWebhook {
+	fakeClient := fake.NewClientBuilder().
+		WithObjects(objects...).
+		Build()
+	return &PackageValidatingWebhook{
+		Client:       fakeClient,
+		OwnerManager: owners.NewOwnerManager(scheme.Scheme),
+		DependendcyManager: dependency.NewDependencyManager(ctrladapter.NewControllerRuntimeAdapter(fakeClient)).
+			WithRepo(&fakeRepo),
+		repo: &fakeRepo,
+	}
+}
+
+var (
+	foov1pkg = v1alpha1.Package{
+		ObjectMeta: v1.ObjectMeta{Name: "foo"},
+		Spec:       v1alpha1.PackageSpec{PackageInfo: v1alpha1.PackageInfoTemplate{Name: "foo", Version: "v1"}}}
+	foov2pkg = v1alpha1.Package{
+		ObjectMeta: v1.ObjectMeta{Name: "foo"},
+		Spec:       v1alpha1.PackageSpec{PackageInfo: v1alpha1.PackageInfoTemplate{Name: "foo", Version: "v2"}}}
+	barv1pkg = v1alpha1.Package{
+		ObjectMeta: v1.ObjectMeta{Name: "bar"},
+		Spec:       v1alpha1.PackageSpec{PackageInfo: v1alpha1.PackageInfoTemplate{Name: "bar", Version: "v1"}}}
+	barv2pkg = v1alpha1.Package{
+		ObjectMeta: v1.ObjectMeta{Name: "bar"},
+		Spec:       v1alpha1.PackageSpec{PackageInfo: v1alpha1.PackageInfoTemplate{Name: "bar", Version: "v2"}}}
+	bazv1pkg = v1alpha1.Package{
+		ObjectMeta: v1.ObjectMeta{Name: "baz"},
+		Spec:       v1alpha1.PackageSpec{PackageInfo: v1alpha1.PackageInfoTemplate{Name: "baz", Version: "v1"}}}
+	bazv2pkg = v1alpha1.Package{
+		ObjectMeta: v1.ObjectMeta{Name: "baz"},
+		Spec:       v1alpha1.PackageSpec{PackageInfo: v1alpha1.PackageInfoTemplate{Name: "baz", Version: "v2"}}}
+	notExistsPkg = v1alpha1.Package{
+		ObjectMeta: v1.ObjectMeta{Name: "doesnotexist"},
+		Spec:       v1alpha1.PackageSpec{PackageInfo: v1alpha1.PackageInfoTemplate{Name: "doesnotexist", Version: "v1"}}}
+)
+
+// These tests demonstrate how unit tests for a CustomValidator COULD be implemented using the controller-runtime fake client.
+// They will likely become useless once the PackageValidatingWebhook does some actual validation.
+// TODO: Add some tests for package validation
+
+var _ = Describe("PackageValidatingWebhook", Ordered, func() {
+	BeforeAll(func() {
+		err := v1alpha1.AddToScheme(scheme.Scheme)
+		Expect(err).NotTo(HaveOccurred())
+		fakeRepo.Packages = map[string]map[string]v1alpha1.PackageManifest{
+			"foo": {
+				"v1": {Dependencies: []v1alpha1.Dependency{{Name: "bar", Version: "v1"}}},
+				"v2": {Dependencies: []v1alpha1.Dependency{{Name: "bar", Version: "v2"}}},
+			},
+			"bar": {
+				"v1": {},
+				"v2": {},
+			},
+			"baz": {
+				"v1": {Dependencies: []v1alpha1.Dependency{{Name: "foo", Version: "v1"}}},
+				"v2": {Dependencies: []v1alpha1.Dependency{{Name: "foo", Version: "v2"}}},
+			},
+		}
+	})
+
+	Context("ValidateCreate", func() {
+		When("cluster is empty", func() {
+			It("should not return error", func(ctx context.Context) {
+				webhook := newPackageValidatingWebhook()
+				_, err := webhook.ValidateCreate(ctx, &foov1pkg)
+				Expect(err).NotTo(HaveOccurred())
+			})
+			When("a package with that name does not exist", func() {
+				It("should return an error", func(ctx context.Context) {
+					webhook := newPackageValidatingWebhook()
+					_, err := webhook.ValidateCreate(ctx, &notExistsPkg)
+					Expect(err).To(HaveOccurred())
+				})
+			})
+		})
+		When("the dependency exists", func() {
+			When("dependency version ok", func() {
+				It("should not return error", func(ctx context.Context) {
+					webhook := newPackageValidatingWebhook(&barv1pkg)
+					_, err := webhook.ValidateCreate(ctx,
+						&foov1pkg)
+					Expect(err).NotTo(HaveOccurred())
+				})
+			})
+			When("there is a version mismatch", func() {
+				It("should return conflict error", func(ctx context.Context) {
+					webhook := newPackageValidatingWebhook(&barv1pkg)
+					_, err := webhook.ValidateCreate(ctx, &foov2pkg)
+					Expect(err).To(And(HaveOccurred(), Satisfy(isErrDependencyConflict)))
+				})
+			})
+		})
+		When("package has transitive dependency", func() {
+			It("should return error", func(ctx context.Context) {
+				webhook := newPackageValidatingWebhook()
+				_, err := webhook.ValidateCreate(ctx, &bazv1pkg)
+				Expect(err).To(And(HaveOccurred(), Satisfy(isErrTransitiveDependency)))
+			})
+		})
+		When("called with object other than Package", func() {
+			It("should return error", func(ctx context.Context) {
+				webhook := newPackageValidatingWebhook()
+				_, err := webhook.ValidateCreate(ctx, &unstructured.Unstructured{})
+				Expect(err).To(HaveOccurred())
+			})
+		})
+	})
+
+	Context("ValidateUpdate", func() {
+		When("cluster is empty", func() {
+			It("should not return error", func(ctx context.Context) {
+				webhook := newPackageValidatingWebhook()
+				_, err := webhook.ValidateUpdate(ctx, &foov1pkg, &foov2pkg)
+				Expect(err).NotTo(HaveOccurred())
+			})
+		})
+		When("the dependency exists", func() {
+			When("dependency version ok", func() {
+				It("should not return error", func(ctx context.Context) {
+					webhook := newPackageValidatingWebhook(&barv2pkg)
+					_, err := webhook.ValidateUpdate(ctx,
+						&foov1pkg, &foov2pkg)
+					Expect(err).NotTo(HaveOccurred())
+				})
+			})
+			When("there is a version mismatch", func() {
+				It("should return conflict error", func(ctx context.Context) {
+					webhook := newPackageValidatingWebhook(&barv1pkg)
+					_, err := webhook.ValidateUpdate(ctx, &foov1pkg, &foov2pkg)
+					Expect(err).To(And(HaveOccurred(), Satisfy(isErrDependencyConflict)))
+				})
+			})
+		})
+		When("package has transitive dependency", func() {
+			It("should return error", func(ctx context.Context) {
+				webhook := newPackageValidatingWebhook()
+				_, err := webhook.ValidateUpdate(ctx, &bazv1pkg, &bazv2pkg)
+				Expect(err).To(And(HaveOccurred(), Satisfy(isErrTransitiveDependency)))
+			})
+		})
+		When("called with object other than Package", func() {
+			It("should return error", func(ctx context.Context) {
+				webhook := newPackageValidatingWebhook()
+				_, err := webhook.ValidateCreate(ctx, &unstructured.Unstructured{})
+				Expect(err).To(HaveOccurred())
+			})
+		})
+	})
+
+	Context("ValidateDelete", func() {
+		When("package has no owner references", func() {
+			It("should not return error", func(ctx context.Context) {
+				webhook := newPackageValidatingWebhook(&foov1pkg)
+				_, err := webhook.ValidateDelete(ctx, &foov1pkg)
+				Expect(err).NotTo(HaveOccurred())
+			})
+		})
+		When("package has owner reference", func() {
+			It("should return an error", func(ctx context.Context) {
+				barv1Copy := barv1pkg.DeepCopy()
+				err := controllerutil.SetOwnerReference(&foov1pkg, barv1Copy, scheme.Scheme)
+				Expect(err).NotTo(HaveOccurred())
+
+				webhook := newPackageValidatingWebhook(&foov1pkg, barv1Copy)
+				_, err = webhook.ValidateDelete(ctx, barv1Copy)
+				Expect(err).To(And(HaveOccurred(), Satisfy(isErrDependencyConflict)))
+			})
+			When("owning package is also being deleted", func() {
+				It("should not return an error", func(ctx context.Context) {
+					foov1Copy := foov1pkg.DeepCopy()
+					foov1Copy.DeletionTimestamp = &v1.Time{Time: time.Now()}
+					foov1Copy.Finalizers = []string{"foregroundDeletion"}
+					barv1Copy := barv1pkg.DeepCopy()
+					err := controllerutil.SetOwnerReference(foov1Copy, barv1Copy, scheme.Scheme)
+					Expect(err).NotTo(HaveOccurred())
+
+					webhook := newPackageValidatingWebhook(foov1Copy, barv1Copy)
+					_, err = webhook.ValidateDelete(ctx, barv1Copy)
+					Expect(err).NotTo(HaveOccurred())
+				})
+			})
+		})
+	})
+})
diff --git a/internal/webhook/webhook_suite_test.go b/internal/webhook/webhook_suite_test.go
new file mode 100644
index 0000000..fbe79ae
--- /dev/null
+++ b/internal/webhook/webhook_suite_test.go
@@ -0,0 +1,33 @@
+/*
+Copyright 2024.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package webhook
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+// These tests use Ginkgo (BDD-style Go testing framework). Refer to
+// http://onsi.github.io/ginkgo/ to learn more about Ginkgo.
+
+func TestWebhooks(t *testing.T) {
+	RegisterFailHandler(Fail)
+
+	RunSpecs(t, "Webhook Suite")
+}