Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
57bc799
controller: add LoginService for simplified CLI login
mangelajo Feb 2, 2026
a182a94
operator: add LoginConfig type to CRD
mangelajo Feb 2, 2026
cbc17be
operator: add login endpoint reconciliation
mangelajo Feb 2, 2026
6035fbd
helm: add login endpoint templates and values
mangelajo Feb 2, 2026
ae07a91
cli: support simplified jmp login [user@]endpoint syntax
mangelajo Feb 2, 2026
ab686f0
e2e: add login endpoint tests
mangelajo Feb 2, 2026
1dbb06e
operator: make CA_BUNDLE_PEM required when cert-manager enabled
mangelajo Feb 2, 2026
b711487
e2e: add NodePort support for login service
mangelajo Feb 2, 2026
d863d84
helm: remove unused login.endpoint field
mangelajo Feb 2, 2026
87ce428
e2e: add LOGIN_ENDPOINT to setup environment
mangelajo Feb 2, 2026
ba905f7
operator: fix NodePort port assignment for all endpoints
mangelajo Feb 2, 2026
6fbf37e
operator: fix CA bundle not being set in login service
mangelajo Feb 2, 2026
540d02f
login: base64 encode CA bundle in auth config response
mangelajo Feb 2, 2026
81a64c1
cli: improve jmp login UX for simplified login format
mangelajo Feb 2, 2026
4ef5f8b
e2e: add assertions to verify CA certificate in config files
mangelajo Feb 2, 2026
414bd16
e2e: fix --name "" parsing issue in login test
mangelajo Feb 3, 2026
1211491
easy-login: remove unnecessary info from login page
mangelajo Feb 3, 2026
5017256
easy-login: fix bats tests
mangelajo Feb 3, 2026
2bb8553
login: add LOGIN_ENDPOINT for landing page display
mangelajo Feb 3, 2026
fc392da
helm: simplify LOGIN_ENDPOINT to only use endpoint field
mangelajo Feb 3, 2026
0a32196
cli: auto-set default client after OIDC login
mangelajo Feb 3, 2026
ac8a5d3
e2e: verify simplified login sets client as default
mangelajo Feb 3, 2026
0bc3dc8
easy-login: fix login default-user and check
mangelajo Feb 3, 2026
2f3d841
easy-login: allow explicit tls secrets for login
mangelajo Feb 3, 2026
367a1d0
easy-login: auto-create login endpoint when not specified
mangelajo Feb 3, 2026
9dccef2
easy-login: fix helm linting
mangelajo Feb 3, 2026
c382b0d
easy-login: avoid e2e test issues by table column wrapping
mangelajo Feb 3, 2026
b263407
easy-login: fix broken nodeport router replicas
mangelajo Feb 3, 2026
0783ecb
easy-login: address review comments
mangelajo Feb 3, 2026
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
63 changes: 63 additions & 0 deletions controller/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ import (
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.
apiserverinstall "k8s.io/apiserver/pkg/apis/apiserver/install"
apiserverv1beta1 "k8s.io/apiserver/pkg/apis/apiserver/v1beta1"
_ "k8s.io/client-go/plugin/pkg/client/auth"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apimachinery/pkg/util/yaml"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
Expand All @@ -47,6 +50,7 @@ import (
"github.com/jumpstarter-dev/jumpstarter-controller/internal/controller"
"github.com/jumpstarter-dev/jumpstarter-controller/internal/oidc"
"github.com/jumpstarter-dev/jumpstarter-controller/internal/service"
"github.com/jumpstarter-dev/jumpstarter-controller/internal/service/login"

// +kubebuilder:scaffold:imports

Expand Down Expand Up @@ -298,6 +302,16 @@ func main() {
os.Exit(1)
}

// Setup Login Service for simplified CLI login
loginService := login.NewServiceFromEnv()
// Extract OIDC configuration from the loaded config for the login service
oidcConfigs := extractOIDCConfigs(mgr.GetAPIReader(), watchNamespace)
loginService.SetOIDCConfig(oidcConfigs)
if err = loginService.SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create service", "service", "Login")
os.Exit(1)
}

if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up health check")
os.Exit(1)
Expand All @@ -313,3 +327,52 @@ func main() {
os.Exit(1)
}
}

// extractOIDCConfigs reads the OIDC configuration from the ConfigMap
func extractOIDCConfigs(reader client.Reader, namespace string) []login.OIDCConfig {
var configmap corev1.ConfigMap
if err := reader.Get(context.Background(), client.ObjectKey{
Namespace: namespace,
Name: "jumpstarter-controller",
}, &configmap); err != nil {
setupLog.Error(err, "unable to read configmap for OIDC config")
return nil
}
Comment on lines +331 to +340
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears that the login service reads the OIDC configuration from the jumpstarter-controller ConfigMap only once at startup and does not refresh it afterward. If the configuration is updated later (for example a new issuer or audience), what will happen? It looks like /v1/auth/config would remain stale until the controller is restarted. We may need to add a watch mechanism to detect and apply ConfigMap changes dynamically (or some kind of documentation to perform a restart).
but It’s also possible that I’m missing an existing refresh path here, so please let me know if this is relevent :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're right. This is a problem already with the general OIDC config I believe. I agree that a watching mechanism should help here. Address the previous bug (OIDC auth is not being refreshed for configmap changes), but also, that now we don't expose updated information on the endpoint. I will add the watcher! thank you :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I am having issues with this part, since it was a known issue, I will open a bug, and let's fix afterwards as a bug.

Opened: #196

I hope it sounds ok.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, fine by me.


// Try new config format first
rawConfig, ok := configmap.Data["config"]
if ok {
var cfg config.Config
if err := yaml.UnmarshalStrict([]byte(rawConfig), &cfg); err == nil {
return jwtAuthenticatorsToOIDCConfigs(cfg.Authentication.JWT)
}
}

// Fall back to legacy authentication format
rawAuth, ok := configmap.Data["authentication"]
if ok {
var auth config.Authentication
if err := yaml.Unmarshal([]byte(rawAuth), &auth); err == nil {
return jwtAuthenticatorsToOIDCConfigs(auth.JWT)
}
}

Comment on lines +351 to +359
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't need the legacy format anymore, remove this.

return nil
}

// jwtAuthenticatorsToOIDCConfigs converts JWT authenticators to login OIDC configs
func jwtAuthenticatorsToOIDCConfigs(authenticators []apiserverv1beta1.JWTAuthenticator) []login.OIDCConfig {
var configs []login.OIDCConfig
for _, auth := range authenticators {
// Skip internal OIDC provider (localhost)
if auth.Issuer.URL == "https://localhost:8085" {
continue
}
configs = append(configs, login.OIDCConfig{
Issuer: auth.Issuer.URL,
ClientID: "jumpstarter-cli", // Default client ID for CLI
Audiences: auth.Issuer.Audiences,
})
}
return configs
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,87 @@ class Route(BaseModel):
)


class LoginRoute(BaseModel):
model_config = ConfigDict(extra="forbid")

enabled: Optional[bool] = Field(
None, description="Whether to enable OpenShift Route for the login endpoint"
)
annotations: Optional[dict[str, str]] = Field(
None, description="Annotations for the login route"
)
labels: Optional[dict[str, str]] = Field(
None, description="Labels for the login route"
)


class LoginIngressTls(BaseModel):
model_config = ConfigDict(extra="forbid")

secretName: Optional[str] = Field(
None, description="Secret name for the TLS certificate"
)


class LoginIngress(BaseModel):
model_config = ConfigDict(extra="forbid")

enabled: Optional[bool] = Field(
None, description="Whether to enable Ingress for the login endpoint"
)
class_: Optional[str] = Field(
None, alias="class", description="IngressClass to use for the login endpoint"
)
annotations: Optional[dict[str, str]] = Field(
None, description="Annotations for the login ingress"
)
labels: Optional[dict[str, str]] = Field(
None, description="Labels for the login ingress"
)
tls: Optional[LoginIngressTls] = None


class LoginNodeport(BaseModel):
model_config = ConfigDict(extra="forbid")

enabled: Optional[bool] = Field(
None, description="Whether to enable NodePort for the login endpoint"
)
port: Optional[Port] = Field(
None, description="NodePort port number for the login service"
)


class LoginTls(BaseModel):
model_config = ConfigDict(extra="forbid")

secretName: Optional[str] = Field(
None,
description="Name of the Kubernetes secret containing tls.crt and tls.key for edge TLS termination",
)


class Login(BaseModel):
model_config = ConfigDict(extra="forbid")

enabled: Optional[bool] = Field(
None, description="Whether to enable the login endpoint for simplified CLI login"
)
hostname: Optional[str] = Field(
None, description="Hostname for the login endpoint"
)
endpoint: Optional[str] = Field(
None, description="The endpoint URL to display in the login landing page"
)
tls: Optional[LoginTls] = Field(
None,
description="TLS configuration for edge termination (used by both route and ingress)",
)
route: Optional[LoginRoute] = None
ingress: Optional[LoginIngress] = None
nodeport: Optional[LoginNodeport] = None
Comment thread
coderabbitai[bot] marked this conversation as resolved.


class PrefixedClaimOrExpression1(BaseModel):
model_config = ConfigDict(extra="forbid")

Expand Down Expand Up @@ -262,6 +343,15 @@ class Router(BaseModel):
nodeSelector: dict[str, str] | None = None


class CertManager(BaseModel):
model_config = ConfigDict(extra="forbid")

enabled: Optional[bool] = Field(
None,
description="Enable cert-manager integration. When enabled, jumpstarter-service-ca-cert configmap is required.",
)


class Model(BaseModel):
model_config = ConfigDict(extra="forbid")

Expand All @@ -282,7 +372,14 @@ class Model(BaseModel):
global_: Optional[Global] = Field(
None, alias="global", description="Global parameters"
)
certManager: Optional[CertManager] = Field(
None,
description="cert-manager integration for automatic TLS certificate management",
)
grpc: Optional[Grpc1] = None
login: Optional[Login] = Field(
None, description="Login endpoint configuration for simplified CLI login"
)
Comment thread
mangelajo marked this conversation as resolved.


print(json.dumps(Model.model_json_schema(), indent=2))
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ spec:
{{ else }}
value: grpc.{{ .Values.global.baseDomain }}:{{ .Values.grpc.tls.port }}
{{ end }}
- name: LOGIN_ENDPOINT
{{ if .Values.login.endpoint }}
value: {{ .Values.login.endpoint }}
{{ else }}
value: login.{{ .Values.global.baseDomain }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{{ end }}
Comment thread
mangelajo marked this conversation as resolved.
- name: CONTROLLER_KEY
valueFrom:
secretKeyRef:
Expand All @@ -85,6 +91,16 @@ spec:
- name: EXTERNAL_KEY_PEM
value: /secrets/tls.key
{{- end }}
- name: CA_BUNDLE_PEM
valueFrom:
configMapKeyRef:
name: jumpstarter-service-ca-cert
key: ca.crt
# When cert-manager is enabled, require the configmap (fail fast on missing cert)
# When disabled, make it optional (users may not have TLS configured)
optional: {{ not .Values.certManager.enabled }}
- name: GIN_MODE
value: release
Comment thread
coderabbitai[bot] marked this conversation as resolved.
image: {{ .Values.image }}:{{ default .Chart.AppVersion .Values.tag }}
imagePullPolicy: {{ .Values.imagePullPolicy }}
name: manager
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{{ if and .Values.login.enabled .Values.login.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
labels:
component: login
{{- if .Values.login.ingress.labels }}
{{- toYaml .Values.login.ingress.labels | nindent 4 }}
{{- end }}
{{- if .Values.login.ingress.annotations }}
annotations:
{{- toYaml .Values.login.ingress.annotations | nindent 4 }}
{{- end }}
name: jumpstarter-login-ing
namespace: {{ default .Release.Namespace .Values.namespace }}
spec:
{{- if .Values.login.ingress.class }}
ingressClassName: {{ .Values.login.ingress.class }}
{{- end }}
rules:
- host: {{ .Values.login.hostname | default (printf "login.%s" .Values.global.baseDomain) | required "a global.baseDomain or a login.hostname must be provided" }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: jumpstarter-login
port:
number: 8086
tls:
- hosts:
- {{ .Values.login.hostname | default (printf "login.%s" .Values.global.baseDomain) }}
{{- /* Use login.tls.secretName (new), fallback to login.ingress.tls.secretName (legacy), then default */}}
secretName: {{ .Values.login.tls.secretName | default .Values.login.ingress.tls.secretName | default "jumpstarter-login-tls" }}
{{ end }}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{{ if and .Values.login.enabled .Values.login.route.enabled }}
apiVersion: route.openshift.io/v1
kind: Route
metadata:
labels:
component: login
{{- if .Values.login.route.labels }}
{{- toYaml .Values.login.route.labels | nindent 4 }}
{{- end }}
{{- if .Values.login.route.annotations }}
annotations:
{{- toYaml .Values.login.route.annotations | nindent 4 }}
{{- end }}
name: jumpstarter-login-route
namespace: {{ default .Release.Namespace .Values.namespace }}
spec:
{{ if .Values.login.hostname }}
host: {{ .Values.login.hostname }}
{{ else }}
host: login.{{ .Values.global.baseDomain | required "a global.baseDomain or a login.hostname must be provided"}}
{{ end }}
port:
targetPort: 8086
tls:
# Edge termination - TLS terminated at the router, plain HTTP to backend
termination: edge
insecureEdgeTerminationPolicy: Redirect
{{- if .Values.login.tls.secretName }}
# Reference TLS certificate from secret (OpenShift 4.14+)
externalCertificate:
name: {{ .Values.login.tls.secretName }}
{{- end }}
to:
kind: Service
name: jumpstarter-login
weight: 100
wildcardPolicy: None
{{ end }}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{{ if .Values.login.enabled }}
apiVersion: v1
kind: Service
metadata:
labels:
control-plane: controller-manager
app.kubernetes.io/name: jumpstarter-controller
component: login
name: jumpstarter-login
namespace: {{ default .Release.Namespace .Values.namespace }}
spec:
{{ if .Values.login.nodeport.enabled }}
type: NodePort
{{ end }}

ports:
- name: login
port: 8086
protocol: TCP
targetPort: 8086
{{ if .Values.login.nodeport.enabled }}
nodePort: {{ .Values.login.nodeport.port }}
{{ end }}
selector:
control-plane: controller-manager
{{ end }}
Loading
Loading