Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
*.tgz
/coverprofile
common.mk
/.secrets/
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,16 @@ test: $(GINKGO) $(KCP) generate ## Run all tests (excludes e2e).
TEST_KCP_ASSETS=$(LOCALBIN) $(GINKGO) -r -cover --fail-fast --require-suite -covermode count --output-dir=$(BUILD_PATH) -coverprofile=coverprofile --skip-package=test/e2e $(testargs)

.PHONY: test-e2e
test-e2e: $(GINKGO) ## Run e2e tests (kind + kcp + helm).
test-e2e: $(GINKGO) ## Run e2e tests (kind + kcp + helm). Set E2E_SHARD_CONFIG=single-shard|multi-shard (default: multi-shard).
$(GINKGO) -r --fail-fast -v --timeout 30m ./test/e2e/ $(testargs)

.PHONY: test-e2e-matrix
test-e2e-matrix: ## Run e2e tests against both shard configs (single-shard, multi-shard).
$(MAKE) clean-e2e
E2E_SHARD_CONFIG=single-shard $(MAKE) test-e2e
$(MAKE) clean-e2e
E2E_SHARD_CONFIG=multi-shard $(MAKE) test-e2e

.PHONY: e2e-cleanup
clean-e2e: ## Remove kind cluster from e2e tests.
-$(KIND) delete cluster --name dep-ctrl-e2e 2>/dev/null
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,12 +201,12 @@ workspace paths to logical cluster names.
for VW URL discovery and full CRUD on `apiexports/content` to manage webhooks in
binding workspaces via the VW.

**system:admin** (shard-local) -- the webhook SA gets shard-wide read access to
`apiexports/content` and `apiexportendpointslices`. This is evaluated by the Bootstrap
Policy Authorizer for every request on the shard, giving the webhook access to all
provider APIExport virtual workspaces without per-workspace RBAC.
No shard-wide RBAC is needed. The webhook watches dependent resources through the
dep-ctrl APIExport's virtual workspace, authorized by dynamically managed
permissionClaims. Providers accept these claims in their APIBinding.

See [docs/getting-started.md](docs/getting-started.md) for the full bootstrap procedure.
See [docs/getting-started.md](docs/getting-started.md) for the full deployment guide
using [kcp-operator](https://github.com/kcp-dev/helm-charts).

## Development

Expand All @@ -228,6 +228,7 @@ make build
make test

# E2E tests (requires kind, helm, docker)
# Deploys a multi-shard kcp via kcp-operator (root + shard1)
make test-e2e
```

Expand Down
7 changes: 7 additions & 0 deletions api/v1alpha1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ type DependentRef struct {
// +kubebuilder:validation:MaxLength=63
// +kubebuilder:validation:Pattern=`^[A-Z][A-Za-z0-9]*$`
Kind string `json:"kind"`

// Resource is the plural resource name of the dependent (e.g., "virtualmachines").
// Used by the webhook to construct the GVR for listing dependent resources.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=63
// +kubebuilder:validation:Pattern=`^[a-z][a-z0-9]*$`
Resource string `json:"resource"`
}

// APIExportReference identifies an APIExport by workspace path and name.
Expand Down
8 changes: 8 additions & 0 deletions api/v1alpha1/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ func TestCRDFieldValidation(t *testing.T) {
valid: []string{"A", "VirtualMachine", "VPC", "Foo123"},
invalid: []string{"", "virtualMachine", "1Foo", "Foo-Bar", "Foo.Bar", "Foo_Bar"},
},
{
path: "spec.dependent.resource",
pattern: `^[a-z][a-z0-9]*$`,
minLength: 1,
maxLength: 63,
valid: []string{"virtualmachines", "vpcs", "subnets"},
invalid: []string{"", "VirtualMachines", "virtual-machines", "virtual_machines"},
},
{
path: "spec.dependencies.items.apiExportRef.path",
pattern: `^[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)*$`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ spec:
resources:
- group: dependencies.opendefense.cloud
name: dependencyrules
schema: v260428-3564c91.dependencyrules.dependencies.opendefense.cloud
schema: v260504-f130a11.dependencyrules.dependencies.opendefense.cloud
storage:
crd: {}
status: {}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
apiVersion: apis.kcp.io/v1alpha1
kind: APIResourceSchema
metadata:
name: v260428-3564c91.dependencyrules.dependencies.opendefense.cloud
name: v260504-f130a11.dependencyrules.dependencies.opendefense.cloud
spec:
group: dependencies.opendefense.cloud
names:
Expand Down Expand Up @@ -134,6 +134,14 @@ spec:
minLength: 1
pattern: ^[A-Z][A-Za-z0-9]*$
type: string
resource:
description: |-
Resource is the plural resource name of the dependent (e.g., "virtualmachines").
Used by the webhook to construct the GVR for listing dependent resources.
maxLength: 63
minLength: 1
pattern: ^[a-z][a-z0-9]*$
type: string
version:
description: Version is the API version of the dependent resource.
maxLength: 63
Expand All @@ -144,6 +152,7 @@ spec:
- apiExportName
- group
- kind
- resource
- version
type: object
required:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ spec:
minLength: 1
pattern: ^[A-Z][A-Za-z0-9]*$
type: string
resource:
description: |-
Resource is the plural resource name of the dependent (e.g., "virtualmachines").
Used by the webhook to construct the GVR for listing dependent resources.
maxLength: 63
minLength: 1
pattern: ^[a-z][a-z0-9]*$
type: string
version:
description: Version is the API version of the dependent resource.
maxLength: 63
Expand All @@ -150,6 +158,7 @@ spec:
- apiExportName
- group
- kind
- resource
- version
type: object
required:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ spec:
- /dependency-webhook
args:
- --api-export-name={{ .Values.apiExportName }}
{{- if .Values.kcpBaseHost }}
- --kcp-base-host={{ .Values.kcpBaseHost }}
{{- end }}
- --webhook-port={{ .Values.webhook.port }}
- --tls-cert-dir={{ .Values.webhook.tlsCertDir }}
- --health-probe-bind-address={{ .Values.webhook.healthProbeBindAddress }}
Expand Down
5 changes: 3 additions & 2 deletions charts/dependency-controller/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ imagePullSecrets: []

apiExportName: dependencies.opendefense.cloud

# Base kcp host URL (without workspace path). Used to resolve workspace paths
# to logical cluster names. If empty, derived from kubeconfig.
# Base kcp host URL (without workspace path). Used by the controller to resolve
# workspace paths to logical cluster names.
# If empty, derived from kubeconfig.
kcpBaseHost: ""

# Controller configuration.
Expand Down
34 changes: 29 additions & 5 deletions cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package main

import (
"context"
"flag"
"os"

Expand All @@ -14,8 +15,8 @@ import (
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"sigs.k8s.io/controller-runtime/pkg/manager"
Expand All @@ -25,6 +26,7 @@ import (

v1alpha1 "go.opendefense.cloud/dependency-controller/api/v1alpha1"
"go.opendefense.cloud/dependency-controller/internal/controller"
"go.opendefense.cloud/dependency-controller/internal/kcp"
)

var scheme = runtime.NewScheme()
Expand Down Expand Up @@ -58,14 +60,37 @@ func main() {

cfg := ctrl.GetConfigOrDie()

if err := kcp.ValidateKubeconfig(cfg); err != nil {
setupLog.Error(err, "invalid kubeconfig")
os.Exit(1)
}

// Derive base config (root kcp URL without workspace path).
baseCfg := rest.CopyConfig(cfg)
baseCfg, err := kcp.BaseConfig(cfg)
if err != nil {
setupLog.Error(err, "unable to derive front-proxy base URL from kubeconfig")
os.Exit(1)
}
if kcpBaseHost != "" {
baseCfg.Host = kcpBaseHost
}

// Resolve the APIExportEndpointSlice name for the dep-ctrl's APIExport.
// The slice name is not necessarily the same as the APIExport name.
directClient, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
setupLog.Error(err, "unable to create client for endpoint slice discovery")
os.Exit(1)
}

ess, err := kcp.FindEndpointSlice(context.Background(), directClient, apiExportName)
if err != nil {
setupLog.Error(err, "unable to find APIExportEndpointSlice", "apiExport", apiExportName)
os.Exit(1)
}

// Create apiexport provider for the dependency-controller's own APIExport.
depCtrlProvider, err := apiexport.New(cfg, apiExportName, apiexport.Options{
depCtrlProvider, err := apiexport.New(cfg, ess.Name, apiexport.Options{
Scheme: scheme,
})
if err != nil {
Expand Down Expand Up @@ -97,7 +122,6 @@ func main() {

// Register the multicluster DependencyRule reconciler.
reconciler := controller.NewDependencyRuleReconciler(mgr)
reconciler.APIExportName = apiExportName
reconciler.BaseConfig = baseCfg

// Wire up webhook installer if configured.
Expand All @@ -110,7 +134,7 @@ func main() {
os.Exit(1)
}
}
reconciler.WebhookInstaller = controller.NewWebhookInstaller(nil, webhookURL, caBundle)
reconciler.WebhookInstaller = controller.NewWebhookInstaller(mgr, webhookURL, caBundle)
}

if err := mcbuilder.ControllerManagedBy(mgr).
Expand Down
38 changes: 29 additions & 9 deletions cmd/webhook/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import (
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"sigs.k8s.io/controller-runtime/pkg/manager"
Expand All @@ -26,6 +26,7 @@ import (
mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"

v1alpha1 "go.opendefense.cloud/dependency-controller/api/v1alpha1"
"go.opendefense.cloud/dependency-controller/internal/kcp"
"go.opendefense.cloud/dependency-controller/internal/webhook"
)

Expand All @@ -41,12 +42,10 @@ func init() {

func main() {
var apiExportName string
var kcpBaseHost string
var webhookPort int
var tlsCertDir string
var healthProbeBindAddress string
flag.StringVar(&apiExportName, "api-export-name", "dependencies.opendefense.cloud", "Name of the dependency-controller's APIExport")
flag.StringVar(&kcpBaseHost, "kcp-base-host", "", "Base kcp host URL (without workspace path). If empty, derived from kubeconfig.")
flag.IntVar(&webhookPort, "webhook-port", 9443, "Port for the webhook server")
flag.StringVar(&tlsCertDir, "tls-cert-dir", "/etc/webhook-tls", "Directory containing tls.crt and tls.key for the webhook server")
flag.StringVar(&healthProbeBindAddress, "health-probe-bind-address", ":8081", "Address to bind the health probe endpoint")
Expand All @@ -60,14 +59,35 @@ func main() {

cfg := ctrl.GetConfigOrDie()

// Derive base config (root kcp URL without workspace path).
baseCfg := rest.CopyConfig(cfg)
if kcpBaseHost != "" {
baseCfg.Host = kcpBaseHost
if err := kcp.ValidateKubeconfig(cfg); err != nil {
setupLog.Error(err, "invalid kubeconfig")
os.Exit(1)
}

// Derive front-proxy base config by stripping the /clusters/... workspace
// path from the kubeconfig host. This base URL is used by the webhook to
// construct per-request clients targeting specific consumer workspaces.
baseCfg, err := kcp.BaseConfig(cfg)
if err != nil {
setupLog.Error(err, "unable to derive front-proxy base URL from kubeconfig")
os.Exit(1)
}

// Resolve the APIExportEndpointSlice name for the dep-ctrl's APIExport.
directClient, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
setupLog.Error(err, "unable to create client for endpoint slice discovery")
os.Exit(1)
}

ess, err := kcp.FindEndpointSlice(context.Background(), directClient, apiExportName)
if err != nil {
setupLog.Error(err, "unable to find APIExportEndpointSlice", "apiExport", apiExportName)
os.Exit(1)
}

// Create apiexport provider for the dependency-controller's own APIExport.
depCtrlProvider, err := apiexport.New(cfg, apiExportName, apiexport.Options{
depCtrlProvider, err := apiexport.New(cfg, ess.Name, apiexport.Options{
Scheme: scheme,
})
if err != nil {
Expand Down Expand Up @@ -95,7 +115,6 @@ func main() {

cacheMgr := &webhook.RuleCacheManager{
DepCtrlManager: mgr,
BaseConfig: baseCfg,
Scheme: scheme,
APIExportName: apiExportName,
Registry: registry,
Expand Down Expand Up @@ -139,6 +158,7 @@ func main() {
validator := &webhook.DeletionValidator{
Registry: registry,
Initialized: initialized,
BaseConfig: baseCfg,
}
mgr.GetWebhookServer().Register("/validate", &ctrlwebhook.Admission{Handler: validator})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ spec:
minLength: 1
pattern: ^[A-Z][A-Za-z0-9]*$
type: string
resource:
description: |-
Resource is the plural resource name of the dependent (e.g., "virtualmachines").
Used by the webhook to construct the GVR for listing dependent resources.
maxLength: 63
minLength: 1
pattern: ^[a-z][a-z0-9]*$
type: string
version:
description: Version is the API version of the dependent resource.
maxLength: 63
Expand All @@ -150,6 +158,7 @@ spec:
- apiExportName
- group
- kind
- resource
- version
type: object
required:
Expand Down
2 changes: 1 addition & 1 deletion config/kcp/apiexport-dependencies.opendefense.cloud.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ spec:
resources:
- group: dependencies.opendefense.cloud
name: dependencyrules
schema: v260428-3564c91.dependencyrules.dependencies.opendefense.cloud
schema: v260504-f130a11.dependencyrules.dependencies.opendefense.cloud
storage:
crd: {}
status: {}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
apiVersion: apis.kcp.io/v1alpha1
kind: APIResourceSchema
metadata:
name: v260428-3564c91.dependencyrules.dependencies.opendefense.cloud
name: v260504-f130a11.dependencyrules.dependencies.opendefense.cloud
spec:
group: dependencies.opendefense.cloud
names:
Expand Down Expand Up @@ -134,6 +134,14 @@ spec:
minLength: 1
pattern: ^[A-Z][A-Za-z0-9]*$
type: string
resource:
description: |-
Resource is the plural resource name of the dependent (e.g., "virtualmachines").
Used by the webhook to construct the GVR for listing dependent resources.
maxLength: 63
minLength: 1
pattern: ^[a-z][a-z0-9]*$
type: string
version:
description: Version is the API version of the dependent resource.
maxLength: 63
Expand All @@ -144,6 +152,7 @@ spec:
- apiExportName
- group
- kind
- resource
- version
type: object
required:
Expand Down
Loading