Skip to content

Commit 74bd080

Browse files
feat(e2e): OIDC e2e test suite (#36)
Add headless OIDC login flow, WaitForSiteAPIWithOIDC, and 5 OIDC-specific tests. Closes #30.
1 parent 2ed7074 commit 74bd080

15 files changed

Lines changed: 1678 additions & 311 deletions

File tree

.github/workflows/e2e.yaml

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
pull_request:
55
branches: [main]
66
push:
7-
branches: [main, "hex/*"]
7+
branches: [main]
88
workflow_dispatch:
99

1010
permissions:
@@ -54,3 +54,57 @@ jobs:
5454
*.kubeconfig
5555
*.log
5656
retention-days: 3
57+
58+
e2e-oidc:
59+
name: OIDC (Dex + auth-code flow)
60+
runs-on: ubuntu-latest
61+
timeout-minutes: 40
62+
steps:
63+
- uses: actions/checkout@v4
64+
65+
- uses: actions/setup-go@v5
66+
with:
67+
go-version: v1.25.0
68+
69+
- name: Install kind
70+
uses: helm/kind-action@v1
71+
with:
72+
install_only: true
73+
74+
- name: Install Helm
75+
uses: azure/setup-helm@v3
76+
77+
- name: Add kedge.localhost to /etc/hosts
78+
run: echo "127.0.0.1 kedge.localhost" | sudo tee -a /etc/hosts
79+
80+
- name: Add Dex hostname to /etc/hosts
81+
run: echo "127.0.0.1 dex.kedge-system.svc.cluster.local" | sudo tee -a /etc/hosts
82+
83+
- name: Add dexidp Helm repo
84+
run: |
85+
helm repo add dexidp https://charts.dexidp.io
86+
helm repo update dexidp
87+
88+
- name: Build binaries
89+
run: make build
90+
91+
- name: Build hub Docker image
92+
run: |
93+
docker build -f deploy/Dockerfile.hub \
94+
-t ghcr.io/faroshq/kedge-hub:test .
95+
96+
- name: Run OIDC e2e
97+
env:
98+
KEDGE_HUB_IMAGE_PULL_POLICY: Never
99+
KEDGE_HUB_IMAGE_TAG: test
100+
run: make e2e-oidc E2E_TIMEOUT=25m
101+
102+
- name: Upload test logs on failure
103+
if: failure()
104+
uses: actions/upload-artifact@v4
105+
with:
106+
name: e2e-oidc-logs
107+
path: |
108+
*.kubeconfig
109+
*.log
110+
retention-days: 3

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,8 +349,11 @@ e2e: e2e-standalone ## Run default e2e suite (standalone)
349349
e2e-standalone: build ## Run standalone e2e suite (embedded kcp + static token, no Dex)
350350
go test ./test/e2e/suites/standalone/... -v -timeout $(E2E_TIMEOUT) $(E2E_FLAGS)
351351

352+
e2e-oidc: build ## Run OIDC e2e suite (Dex OIDC provider, requires --with-dex cluster)
353+
go test ./test/e2e/suites/oidc/... -v -timeout $(E2E_TIMEOUT) $(E2E_FLAGS)
354+
352355
e2e-all: build ## Run all e2e suites
353-
go test ./test/e2e/... -v -timeout 30m $(E2E_FLAGS)
356+
go test ./test/e2e/suites/... -v -timeout 30m $(E2E_FLAGS)
354357

355358
e2e-keep: ## Run standalone e2e, keep clusters on failure for debugging
356359
$(MAKE) e2e-standalone E2E_FLAGS="--keep-clusters"

pkg/cli/cmd/dev/plugin/create.go

Lines changed: 175 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,30 @@ type DevOptions struct {
6868
HubHTTPSPort int
6969
HubHTTPPort int
7070
ImagePullPolicy string
71+
72+
// WithDex enables Dex as an embedded OIDC identity provider.
73+
// When true, Dex is deployed into the hub kind cluster and the hub is
74+
// configured with the Dex issuer URL automatically.
75+
WithDex bool
76+
DexHTTPPort int // host port for the Dex NodePort mapping (default 5556)
7177
}
7278

7379
// fallbackAssetVersion is used when unable to fetch the latest version
7480
const fallbackAssetVersion = "0.0.1"
7581

82+
// Dex constants used when --with-dex is set.
83+
const (
84+
devDexIssuerURL = "http://dex.kedge-system.svc.cluster.local:5556/dex"
85+
devDexClientID = "kedge"
86+
devDexClientSecret = "kedge-test-secret"
87+
devDexChartRef = "dexidp/dex" // from https://charts.dexidp.io, added as a repo
88+
devDexChartVersion = "0.24.0"
89+
devDexReleaseName = "dex"
90+
devDexNodePort = 31556
91+
// bcrypt of "Password1!" for the dev Dex static user
92+
devDexUserHash = "$2a$10$ntVcHD0gEYObjVin2ti7XuMILVz0rTQl//HVPc3cR8z7AAVbQGrkO"
93+
)
94+
7695
// gitHubRelease represents a GitHub release response
7796
type gitHubRelease struct {
7897
TagName string `json:"tag_name"`
@@ -90,6 +109,7 @@ func NewDevOptions(streams genericclioptions.IOStreams) *DevOptions {
90109
APIServerPort: 6443,
91110
HubHTTPSPort: 8443,
92111
HubHTTPPort: 8080,
112+
DexHTTPPort: 5556,
93113
}
94114
}
95115

@@ -108,6 +128,8 @@ func (o *DevOptions) AddCmdFlags(cmd *cobra.Command) {
108128
cmd.Flags().IntVar(&o.HubHTTPSPort, "hub-https-port", 8443, "HTTPS port for kedge hub (change if 8443 is already in use)")
109129
cmd.Flags().IntVar(&o.HubHTTPPort, "hub-http-port", 8080, "HTTP port for kedge hub (change if 8080 is already in use)")
110130
cmd.Flags().StringVar(&o.ImagePullPolicy, "image-pull-policy", "IfNotPresent", "Image pull policy for the hub (use Never when the image is pre-loaded into kind)")
131+
cmd.Flags().BoolVar(&o.WithDex, "with-dex", false, "Deploy Dex as OIDC identity provider into the hub kind cluster")
132+
cmd.Flags().IntVar(&o.DexHTTPPort, "dex-http-port", 5556, "Host port for the Dex NodePort mapping (default 5556)")
111133
}
112134

113135
// Complete completes the options
@@ -180,6 +202,14 @@ func (o *DevOptions) Validate() error {
180202
}
181203

182204
func (o *DevOptions) hubClusterConfig() string {
205+
dexMapping := ""
206+
if o.WithDex {
207+
dexMapping = fmt.Sprintf(` - containerPort: 31556
208+
hostPort: %d
209+
protocol: TCP
210+
listenAddress: "127.0.0.1"
211+
`, o.DexHTTPPort)
212+
}
183213
return fmt.Sprintf(`apiVersion: kind.x-k8s.io/v1alpha4
184214
kind: Cluster
185215
networking:
@@ -196,7 +226,7 @@ nodes:
196226
hostPort: %d
197227
protocol: TCP
198228
listenAddress: "127.0.0.1"
199-
`, o.APIServerPort, o.HubHTTPPort, o.HubHTTPSPort)
229+
%s`, o.APIServerPort, o.HubHTTPPort, o.HubHTTPSPort, dexMapping)
200230
}
201231

202232
var agentClusterConfig = `apiVersion: kind.x-k8s.io/v1alpha4
@@ -392,7 +422,20 @@ func (o *DevOptions) createCluster(ctx context.Context, clusterName, clusterConf
392422
if err != nil {
393423
return err
394424
}
395-
if err := o.installHelmChart(ctx, restConfig); err != nil {
425+
426+
// Deploy Dex FIRST so the hub can be installed once, with IDP settings
427+
// already wired in. Dex creates the namespace (CreateNamespace=true).
428+
if o.WithDex {
429+
_, _ = fmt.Fprint(o.Streams.ErrOut, "Deploying Dex OIDC provider...\n")
430+
if err := o.deployDex(ctx, restConfig, kubeconfigPath); err != nil {
431+
return fmt.Errorf("deploying dex: %w", err)
432+
}
433+
_, _ = fmt.Fprint(o.Streams.ErrOut, "Dex deployed\n")
434+
}
435+
436+
// Single hub install: pass withIDP=o.WithDex so IDP settings are
437+
// included from the start when Dex is enabled.
438+
if err := o.installHelmChart(ctx, restConfig, o.WithDex); err != nil {
396439
_, _ = fmt.Fprint(o.Streams.ErrOut, "Failed to install Helm chart\n")
397440
return err
398441
}
@@ -406,6 +449,108 @@ func loadRestConfigFromFile(kubeconfigPath string) (*rest.Config, error) {
406449
return clientcmd.BuildConfigFromFlags("", kubeconfigPath)
407450
}
408451

452+
// ensureDexHelmRepo adds the dexidp helm repo if it isn't already present.
453+
// This is needed so that `helm install dexidp/dex` can resolve the chart.
454+
func ensureDexHelmRepo() error {
455+
addCmd := exec.Command("helm", "repo", "add", "dexidp", "https://charts.dexidp.io")
456+
// "already exists" is not an error; any other failure is.
457+
out, err := addCmd.CombinedOutput()
458+
if err != nil && !strings.Contains(string(out), "already exists") {
459+
return fmt.Errorf("adding dexidp helm repo: %w\noutput: %s", err, string(out))
460+
}
461+
updateCmd := exec.Command("helm", "repo", "update", "dexidp")
462+
if out, err := updateCmd.CombinedOutput(); err != nil {
463+
return fmt.Errorf("updating dexidp helm repo: %w\noutput: %s", err, string(out))
464+
}
465+
return nil
466+
}
467+
468+
// deployDex installs or upgrades the Dex Helm chart into the hub kind cluster
469+
// and blocks until the Dex pod is Running/Ready.
470+
func (o *DevOptions) deployDex(ctx context.Context, restConfig *rest.Config, kubeconfigPath string) error {
471+
actionConfig := new(action.Configuration)
472+
if err := actionConfig.Init(&restConfigGetter{config: restConfig}, "kedge-system", "secret",
473+
func(format string, v ...any) {}); err != nil {
474+
return fmt.Errorf("initialising helm action config for dex: %w", err)
475+
}
476+
regClient, err := registry.NewClient()
477+
if err != nil {
478+
return fmt.Errorf("creating helm registry client for dex: %w", err)
479+
}
480+
actionConfig.RegistryClient = regClient
481+
482+
hubExternalURL := fmt.Sprintf("https://kedge.localhost:%d", o.HubHTTPSPort)
483+
redirectURI := hubExternalURL + "/auth/callback"
484+
485+
dexValues := map[string]any{
486+
"image": map[string]any{"tag": "v2.44.0"},
487+
"service": map[string]any{
488+
"type": "NodePort",
489+
"ports": map[string]any{
490+
"http": map[string]any{"nodePort": devDexNodePort},
491+
},
492+
},
493+
"config": map[string]any{
494+
"issuer": devDexIssuerURL,
495+
"storage": map[string]any{"type": "memory"},
496+
"web": map[string]any{"http": "0.0.0.0:5556"},
497+
"oauth2": map[string]any{"skipApprovalScreen": true},
498+
"staticClients": []map[string]any{{
499+
"id": devDexClientID,
500+
"secret": devDexClientSecret,
501+
"name": "Kedge Hub",
502+
"redirectURIs": []string{redirectURI},
503+
}},
504+
"enablePasswordDB": true,
505+
"staticPasswords": []map[string]any{{
506+
"email": "admin@test.kedge.local",
507+
"hash": devDexUserHash,
508+
"username": "admin",
509+
"userID": "test-user-id-01",
510+
}},
511+
},
512+
}
513+
514+
if err := ensureDexHelmRepo(); err != nil {
515+
return fmt.Errorf("ensuring dex helm repo: %w", err)
516+
}
517+
518+
tmp := action.NewInstall(actionConfig)
519+
tmp.Version = devDexChartVersion
520+
chartPath, err := tmp.LocateChart(devDexChartRef, cli.New())
521+
if err != nil {
522+
return fmt.Errorf("locating dex chart: %w", err)
523+
}
524+
chartObj, err := loader.Load(chartPath)
525+
if err != nil {
526+
return fmt.Errorf("loading dex chart: %w", err)
527+
}
528+
529+
hist := action.NewHistory(actionConfig)
530+
hist.Max = 1
531+
if _, err := hist.Run(devDexReleaseName); err == nil {
532+
upg := action.NewUpgrade(actionConfig)
533+
upg.Namespace = "kedge-system"
534+
upg.Wait = true
535+
upg.Timeout = 3 * time.Minute
536+
if _, err := upg.Run(devDexReleaseName, chartObj, dexValues); err != nil {
537+
return fmt.Errorf("upgrading dex chart: %w", err)
538+
}
539+
} else {
540+
inst := action.NewInstall(actionConfig)
541+
inst.ReleaseName = devDexReleaseName
542+
inst.Namespace = "kedge-system"
543+
inst.CreateNamespace = true // Dex is deployed before the hub; create namespace here.
544+
inst.Wait = true
545+
inst.Timeout = 3 * time.Minute
546+
if _, err := inst.Run(chartObj, dexValues); err != nil {
547+
return fmt.Errorf("installing dex chart: %w", err)
548+
}
549+
}
550+
551+
return nil
552+
}
553+
409554
func (o *DevOptions) getClusterIPAddress(ctx context.Context, clusterName, networkName string) (string, error) {
410555
dockerClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
411556
if err != nil {
@@ -443,7 +588,11 @@ func (o *DevOptions) getClusterIPAddress(ctx context.Context, clusterName, netwo
443588
return "", fmt.Errorf("could not find IP address for cluster %s in network %s", clusterName, networkName)
444589
}
445590

446-
func (o *DevOptions) installHelmChart(_ context.Context, restConfig *rest.Config) error {
591+
// installHelmChart installs or upgrades the kedge-hub Helm chart.
592+
// withIDP controls whether IDP/OIDC values are included; pass false for the
593+
// initial install (before Dex is deployed) and true for the upgrade after Dex
594+
// is up, so the hub never tries to contact a non-existent issuer at startup.
595+
func (o *DevOptions) installHelmChart(_ context.Context, restConfig *rest.Config, withIDP bool) error {
447596
actionConfig := new(action.Configuration)
448597

449598
if err := actionConfig.Init(&restConfigGetter{config: restConfig}, "kedge-system", "secret", func(format string, v ...any) {}); err != nil {
@@ -457,6 +606,21 @@ func (o *DevOptions) installHelmChart(_ context.Context, restConfig *rest.Config
457606
}
458607
actionConfig.RegistryClient = registryClient
459608

609+
hubExternalURL := fmt.Sprintf("https://kedge.localhost:%d", o.HubHTTPSPort)
610+
611+
hubValues := map[string]any{
612+
"hubExternalURL": hubExternalURL,
613+
"listenAddr": fmt.Sprintf(":%d", o.HubHTTPSPort),
614+
"devMode": true,
615+
}
616+
// Static auth token is only used in token mode. In OIDC/IDP mode the hub
617+
// authenticates via Dex; mixing both would be confusing and unnecessary.
618+
if !withIDP {
619+
hubValues["staticAuthTokens"] = []string{"dev-token"}
620+
}
621+
// IDP settings are passed via the top-level `idp` helm values (not under `hub`).
622+
// See deploy/charts/kedge-hub/templates/statefulset.yaml.
623+
460624
values := map[string]any{
461625
"image": map[string]any{
462626
"hub": map[string]any{
@@ -465,14 +629,7 @@ func (o *DevOptions) installHelmChart(_ context.Context, restConfig *rest.Config
465629
"pullPolicy": o.ImagePullPolicy,
466630
},
467631
},
468-
"hub": map[string]any{
469-
"hubExternalURL": fmt.Sprintf("https://kedge.localhost:%d", o.HubHTTPSPort),
470-
"listenAddr": fmt.Sprintf(":%d", o.HubHTTPSPort),
471-
"devMode": true,
472-
"staticAuthTokens": []string{
473-
"dev-token",
474-
},
475-
},
632+
"hub": hubValues,
476633
"service": map[string]any{
477634
"type": "NodePort",
478635
"hub": map[string]any{
@@ -481,6 +638,13 @@ func (o *DevOptions) installHelmChart(_ context.Context, restConfig *rest.Config
481638
},
482639
},
483640
}
641+
if withIDP && o.WithDex {
642+
values["idp"] = map[string]any{
643+
"issuerURL": devDexIssuerURL,
644+
"clientID": devDexClientID,
645+
"clientSecret": devDexClientSecret,
646+
}
647+
}
484648

485649
var chartObj *chart.Chart
486650
var err error

pkg/cli/cmd/site.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func newSiteCommand() *cobra.Command {
3838
newSiteCreateCommand(),
3939
newSiteListCommand(),
4040
newSiteGetCommand(),
41+
newSiteDeleteCommand(),
4142
)
4243

4344
return cmd
@@ -194,6 +195,30 @@ func newSiteGetCommand() *cobra.Command {
194195
}
195196
}
196197

198+
func newSiteDeleteCommand() *cobra.Command {
199+
return &cobra.Command{
200+
Use: "delete <name>",
201+
Short: "Delete a site",
202+
Args: cobra.ExactArgs(1),
203+
RunE: func(cmd *cobra.Command, args []string) error {
204+
name := args[0]
205+
ctx := context.Background()
206+
207+
dynClient, err := loadDynamicClient()
208+
if err != nil {
209+
return err
210+
}
211+
212+
if err := dynClient.Resource(kedgeclient.SiteGVR).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
213+
return fmt.Errorf("deleting site %q: %w", name, err)
214+
}
215+
216+
fmt.Printf("Site %q deleted.\n", name)
217+
return nil
218+
},
219+
}
220+
}
221+
197222
func unstructuredNestedBool(obj map[string]interface{}, fields ...string) (bool, bool, error) {
198223
val, found, err := unstructuredNestedField(obj, fields...)
199224
if err != nil || !found {

0 commit comments

Comments
 (0)