Skip to content

Commit 9e7cdba

Browse files
authored
Merge pull request #141 from SUSE/feat/operator-ns-configmap-sync
feat: auto-sync operator coordinates to aif-ui-config ConfigMap
2 parents 054451b + bd61b6a commit 9e7cdba

10 files changed

Lines changed: 141 additions & 14 deletions

File tree

aif-operator/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM --platform=$BUILDPLATFORM registry.suse.com/bci/golang:1.25 as builder
1+
FROM --platform=$BUILDPLATFORM registry.suse.com/bci/golang:1.25 AS builder
22
ARG TARGETOS
33
ARG TARGETARCH
44
ARG VERSION=unknown

aif-operator/cmd/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@ func main() {
190190
// operatorNamespace secrets; aiworkload controller needs Helm
191191
// release secrets (owner=helm) from any target namespace.
192192
&corev1.Secret{}: {},
193+
// Restrict ConfigMap watch to the extension namespace — the namespaced
194+
// Role in cattle-ui-plugin-system grants watch; the ClusterRole does not.
195+
&corev1.ConfigMap{}: {
196+
Namespaces: map[string]cache.Config{
197+
config.GetExtensionNamespace(): {},
198+
},
199+
},
193200
},
194201
},
195202
// LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily

aif-operator/internal/config/runtime.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,12 @@ func GetOperatorNamespace() string {
1919
}
2020
return DefaultOperatorNamespace
2121
}
22+
23+
const DefaultOperatorService = "aif-operator"
24+
25+
func GetOperatorService() string {
26+
if svc := os.Getenv("OPERATOR_SERVICE"); svc != "" {
27+
return svc
28+
}
29+
return DefaultOperatorService
30+
}

aif-operator/internal/controller/installaiextension/installaiextension_controller.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@ import (
1111
urlpkg "net/url"
1212

1313
"helm.sh/helm/v3/pkg/cli"
14+
corev1 "k8s.io/api/core/v1"
1415
"k8s.io/apimachinery/pkg/api/meta"
1516
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1617
"k8s.io/apimachinery/pkg/runtime"
1718
ctrl "sigs.k8s.io/controller-runtime"
1819
"sigs.k8s.io/controller-runtime/pkg/client"
20+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
1921
"sigs.k8s.io/controller-runtime/pkg/log"
2022

2123
v1alpha1 "github.com/SUSE/aif-operator/api/v1alpha1"
24+
"github.com/SUSE/aif-operator/internal/config"
2225
helmClient "github.com/SUSE/aif-operator/internal/infra/helm"
2326
"github.com/SUSE/aif-operator/internal/infra/kubernetes"
2427
"github.com/SUSE/aif-operator/internal/infra/rancher"
@@ -28,6 +31,7 @@ import (
2831
const (
2932
defaultReadinessTimeout = 5 * time.Minute
3033
readinessRequeue = 10 * time.Second
34+
uiConfigMapName = "aif-ui-config"
3135
healthCheckInterval = 60 * time.Second
3236

3337
conditionTypeReady = "Ready"
@@ -143,10 +147,42 @@ func (r *InstallAIExtensionReconciler) reconcile(ctx context.Context, ext *v1alp
143147
ext.Status.ActiveExtensionName = ext.Spec.Extension.Name
144148
ext.Status.ActiveSourceKind = ext.Spec.Source.Kind
145149

150+
if err := r.syncUIConfigMap(ctx); err != nil {
151+
logger.Error(err, "failed to sync operator coordinates to UI ConfigMap")
152+
return ctrl.Result{Requeue: true}, nil
153+
}
154+
146155
logger.Info("reconciled successfully")
147156
return ctrl.Result{RequeueAfter: healthCheckInterval}, nil
148157
}
149158

159+
// syncUIConfigMap writes the operator namespace and service name into the
160+
// aif-ui-config ConfigMap so the UI extension can reach the operator without
161+
// manual configuration. It runs on every successful reconcile loop, giving
162+
// self-healing behaviour if the ConfigMap is deleted or corrupted.
163+
// The ConfigMap is intentionally not deleted when the CR is removed — the UI
164+
// retains the last-known operator coordinates so it remains functional.
165+
func (r *InstallAIExtensionReconciler) syncUIConfigMap(ctx context.Context) error {
166+
logger := log.FromContext(ctx)
167+
ns, svc := config.GetOperatorNamespace(), config.GetOperatorService()
168+
logger.V(1).Info("syncing UI ConfigMap", "operatorNamespace", ns, "operatorService", svc)
169+
cm := &corev1.ConfigMap{
170+
ObjectMeta: metav1.ObjectMeta{
171+
Name: uiConfigMapName,
172+
Namespace: r.ExtensionNamespace,
173+
},
174+
}
175+
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, cm, func() error {
176+
if cm.Data == nil {
177+
cm.Data = make(map[string]string)
178+
}
179+
cm.Data["operatorNamespace"] = ns
180+
cm.Data["operatorService"] = svc
181+
return nil
182+
})
183+
return err
184+
}
185+
150186
func (r *InstallAIExtensionReconciler) reconcileHelmSource(
151187
ctx context.Context,
152188
ext *v1alpha1.InstallAIExtension,

charts/aif-operator/templates/extension/installaiextension.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ spec:
1212
helm:
1313
chartURL: {{ .Values.aiExtension.source.helm.chartURL | quote }}
1414
version: {{ .Values.aiExtension.source.helm.version | quote }}
15+
{{- with .Values.aiExtension.source.helm.values }}
16+
values:
17+
{{- toYaml . | nindent 8 }}
18+
{{- end }}
1519
{{- end }}
1620
extension:
1721
name: {{ .Values.aiExtension.extension.name | quote }}

charts/aif-operator/templates/manager/manager.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ spec:
6161
valueFrom:
6262
fieldRef:
6363
fieldPath: metadata.namespace
64+
- name: OPERATOR_SERVICE
65+
# Static at install time (chart-derived name); not a fieldRef because
66+
# the service name is determined by the chart, not the pod's runtime state.
67+
value: {{ include "aif-operator.fullname" . | quote }}
6468
- name: EXTENSION_NAMESPACE
6569
value: {{ include "aif-operator.extensionsNamespace" . | quote }}
6670
- name: CHART_VERSION

charts/aif-operator/values.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ aiExtension:
108108
helm:
109109
chartURL: "oci://ghcr.io/suse/chart/aif-ui"
110110
version: "0.1.0-dev.4"
111+
# values are merged onto the aif-ui chart's default values. Useful for
112+
# overriding the UI image during development:
113+
# aiExtension.source.helm.values.image.registry=ghcr.io
114+
# aiExtension.source.helm.values.image.repository=suse/aif-ui
115+
# aiExtension.source.helm.values.image.tag=0.1.0-dev.4
116+
values: {}
111117
extension:
112118
name: "aif-ui"
113119
version: "0.1.0-dev.4"

pkg/aif-ui/l10n/en-us.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@
115115
"warning": "Advanced settings for air-gapped or custom deployments only. Modify with care.",
116116
"operatorConnection": {
117117
"title": "Operator Connection",
118+
"managed": "Operator namespace and service are managed by an InstallAIExtension resource. The operator updates these values automatically — editing is disabled.",
119+
"forbidden": "Cannot verify whether the operator connection is managed — access to InstallAIExtension resources is restricted. If a resource exists, changes saved here may be overwritten by the operator.",
118120
"found": "Configuration loaded from the aif-ui-config ConfigMap in cattle-ui-plugin-system. Changes take effect immediately on save.",
119121
"notFound": "No aif-ui-config ConfigMap found in cattle-ui-plugin-system. Fields below show defaults. Saving will create the ConfigMap.",
120122
"namespace": {

pkg/aif-ui/pages/Settings.vue

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { Checkbox } from '@components/Form/Checkbox';
99
import SecretSelector from '@shell/components/form/SecretSelector';
1010
import { getSettings, putSettings } from '../utils/operator-api';
1111
import { TIMEOUT_VALUES } from '../utils/constants';
12-
import { loadOperatorConfig, getOperatorConfig, getOperatorNamespace, saveOperatorConfig, isConfigMapFound } from '../utils/operator-config';
12+
import { loadOperatorConfig, getOperatorConfig, getOperatorNamespace, saveOperatorConfig, isConfigMapFound, hasInstallAIExtension, isExtensionCheckForbidden } from '../utils/operator-config';
1313
import { ensureClusterRepo } from '../services/rancher-apps';
1414
import { APP_COLLECTION_REPO_URL, SUSE_REGISTRY_REPO_URL, NVIDIA_REPO_URL, NVIDIA_BLUEPRINT_REPO_URL } from '../services/app-collection';
1515
@@ -39,7 +39,11 @@ export default {
3939
},
4040
4141
async fetch() {
42-
await loadOperatorConfig();
42+
[this.operatorManaged] = await Promise.all([
43+
hasInstallAIExtension(),
44+
loadOperatorConfig(),
45+
]);
46+
this.operatorForbidden = isExtensionCheckForbidden();
4347
const operatorCfg = getOperatorConfig();
4448
this.operatorNamespace = operatorCfg.namespace;
4549
this.operatorService = operatorCfg.service;
@@ -68,9 +72,11 @@ export default {
6872
fetchErrorMessage: null,
6973
errors: [],
7074
mode: 'edit',
71-
operatorNamespace: '',
72-
operatorService: '',
75+
operatorNamespace: '',
76+
operatorService: '',
7377
operatorConfigMapFound: false,
78+
operatorManaged: false,
79+
operatorForbidden: false,
7480
expanded: {
7581
fleet: false,
7682
appCollection: true,
@@ -362,8 +368,11 @@ export default {
362368
// that the subsequent putSettings call reaches the correct operator URL.
363369
// If the user is correcting a wrong namespace, putSettings would fail
364370
// against the old URL if called before the cache is updated.
365-
await saveOperatorConfig(this.operatorNamespace || 'aif-operator', this.operatorService || 'aif-operator');
366-
this.operatorConfigMapFound = true;
371+
// Skip when managed by InstallAIExtension — the reconciler owns the ConfigMap.
372+
if (!this.operatorManaged) {
373+
await saveOperatorConfig(this.operatorNamespace || 'aif-operator', this.operatorService || 'aif-operator');
374+
this.operatorConfigMapFound = true;
375+
}
367376
const data = await putSettings(this.buildCrdSpec(this.spec));
368377
369378
this.spec = this.buildSpec(data.spec);
@@ -705,7 +714,19 @@ export default {
705714
{{ t('suseai.pages.settings.sections.advanced.operatorConnection.title') }}
706715
</h3>
707716
<Banner
708-
v-if="operatorConfigMapFound"
717+
v-if="operatorManaged"
718+
color="info"
719+
:label="t('suseai.pages.settings.sections.advanced.operatorConnection.managed')"
720+
class="mb-15"
721+
/>
722+
<Banner
723+
v-else-if="operatorForbidden"
724+
color="warning"
725+
:label="t('suseai.pages.settings.sections.advanced.operatorConnection.forbidden')"
726+
class="mb-15"
727+
/>
728+
<Banner
729+
v-else-if="operatorConfigMapFound"
709730
color="info"
710731
:label="t('suseai.pages.settings.sections.advanced.operatorConnection.found')"
711732
class="mb-15"
@@ -722,15 +743,15 @@ export default {
722743
v-model:value="operatorNamespace"
723744
:label="t('suseai.pages.settings.sections.advanced.operatorConnection.namespace.label')"
724745
:placeholder="t('suseai.pages.settings.sections.advanced.operatorConnection.namespace.placeholder')"
725-
:mode="mode"
746+
:mode="operatorManaged ? 'view' : mode"
726747
/>
727748
</div>
728749
<div class="col span-4">
729750
<LabeledInput
730751
v-model:value="operatorService"
731752
:label="t('suseai.pages.settings.sections.advanced.operatorConnection.service.label')"
732753
:placeholder="t('suseai.pages.settings.sections.advanced.operatorConnection.service.placeholder')"
733-
:mode="mode"
754+
:mode="operatorManaged ? 'view' : mode"
734755
/>
735756
</div>
736757
</div>

pkg/aif-ui/utils/operator-config.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ let cache: OperatorCache | null = null;
2323
let loadPromise: Promise<void> | null = null;
2424
let connectionError: string | null = null;
2525
let checkPromise: Promise<void> | null = null;
26+
let checkExtPromise: Promise<boolean> | null = null;
27+
let extensionForbidden = false;
2628

2729
function configMapUrl(): string {
2830
return `/k8s/clusters/${ MANAGEMENT_CLUSTER }/api/v1/namespaces/${ CONFIG_NAMESPACE }/configmaps/${ CONFIG_MAP_NAME }`;
@@ -147,11 +149,47 @@ export function isConfigMapFound(): boolean {
147149
return cache?.found ?? false;
148150
}
149151

152+
/** Returns true when at least one InstallAIExtension CR exists in the cluster,
153+
* meaning the operator owns the ConfigMap and the Settings fields should be read-only.
154+
* Returns false on any error (404 = CRD not installed, network error).
155+
* On 403, sets the extensionForbidden flag — call isExtensionCheckForbidden() to
156+
* distinguish "not managed" from "cannot determine". Idempotent: subsequent calls
157+
* return the shared in-flight promise; pass force=true to re-run the check. */
158+
export function hasInstallAIExtension(force = false): Promise<boolean> {
159+
if (force) { checkExtPromise = null; extensionForbidden = false; }
160+
if (!checkExtPromise) checkExtPromise = _doCheckExtension();
161+
return checkExtPromise;
162+
}
163+
164+
async function _doCheckExtension(): Promise<boolean> {
165+
try {
166+
const url = `/k8s/clusters/${ MANAGEMENT_CLUSTER }/apis/ai-platform.suse.com/v1alpha1/installaiextensions`;
167+
const res = await fetch(url, { headers: { Accept: 'application/json' } });
168+
if (res.status === 403) {
169+
extensionForbidden = true;
170+
return false;
171+
}
172+
if (!res.ok) return false;
173+
const body = await res.json().catch(() => null);
174+
return Array.isArray(body?.items) && body.items.length > 0;
175+
} catch {
176+
return false;
177+
}
178+
}
179+
180+
/** Returns true when hasInstallAIExtension() returned false due to a 403 —
181+
* meaning the managed state is unknown, not confirmed absent. */
182+
export function isExtensionCheckForbidden(): boolean {
183+
return extensionForbidden;
184+
}
185+
150186
export function invalidateOperatorConfig(): void {
151-
cache = null;
152-
loadPromise = null;
153-
connectionError = null;
154-
checkPromise = null;
187+
cache = null;
188+
loadPromise = null;
189+
connectionError = null;
190+
checkPromise = null;
191+
checkExtPromise = null;
192+
extensionForbidden = false;
155193
}
156194

157195
/** Write operator coordinates to the ConfigMap and refresh the in-memory cache.

0 commit comments

Comments
 (0)