@@ -2,10 +2,12 @@ package k8sapp
22
33import (
44 "context"
5+ "encoding/base64"
56 "encoding/json"
67 "net/url"
78 "sync"
89
10+ "golang.org/x/sync/singleflight"
911 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1012 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1113 "k8s.io/apimachinery/pkg/runtime"
@@ -19,6 +21,7 @@ import (
1921 "github.com/keboola/keboola-as-code/internal/pkg/log"
2022 "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/api"
2123 "github.com/keboola/keboola-as-code/internal/pkg/service/common/servicectx"
24+ "github.com/keboola/keboola-as-code/internal/pkg/utils/errors"
2225)
2326
2427// entry stores the K8s object name and last observed state for an app.
@@ -27,16 +30,18 @@ type entry struct {
2730 state AppActualState
2831 autoRestartEnabled bool
2932 upstreamTarget * url.URL // pre-parsed; nil when appsProxy.upstreamUrl absent/invalid
33+ e2bAccessToken string // loaded from K8s Secret; empty for non-E2B apps
34+ e2bSecretName string // Secret name for lazy token loading; empty for non-E2B apps
3035}
3136
3237// StateWatcher watches App CRDs in Kubernetes and provides a local cache of app states.
3338type StateWatcher struct {
34- client dynamic.Interface
35- namespace string
36- logger log.Logger
37- hasSynced cache.InformerSynced
38- // apps: AppID → entry
39- apps sync. Map
39+ client dynamic.Interface
40+ namespace string
41+ logger log.Logger
42+ hasSynced cache.InformerSynced
43+ apps sync. Map // AppID → entry
44+ tokenLoadGroup singleflight. Group // coalesces concurrent lazy-load K8s API calls per secret
4045}
4146
4247type dependencies interface {
@@ -69,10 +74,10 @@ func NewStateWatcher(d dependencies, client dynamic.Interface, namespace string)
6974 })
7075
7176 lw := & cache.ListWatch {
72- ListFunc : func (opts metav1.ListOptions ) (runtime.Object , error ) {
77+ ListWithContextFunc : func (ctx context. Context , opts metav1.ListOptions ) (runtime.Object , error ) {
7378 return client .Resource (AppGVR ()).Namespace (namespace ).List (ctx , opts )
7479 },
75- WatchFunc : func (opts metav1.ListOptions ) (watch.Interface , error ) {
80+ WatchFuncWithContext : func (ctx context. Context , opts metav1.ListOptions ) (watch.Interface , error ) {
7681 return client .Resource (AppGVR ()).Namespace (namespace ).Watch (ctx , opts )
7782 },
7883 }
@@ -114,16 +119,34 @@ func NewStateWatcher(d dependencies, client dynamic.Interface, namespace string)
114119}
115120
116121// GetState returns the cached AppInfo for the app. Returns (AppInfo{}, false) if not yet cached.
122+ // If the E2B access token is missing but a secret name is known, it attempts to load the token lazily.
117123func (w * StateWatcher ) GetState (appID api.AppID ) (AppInfo , bool ) {
118124 v , ok := w .apps .Load (appID )
119125 if ! ok {
120126 return AppInfo {}, false
121127 }
122128 e := v .(entry )
129+
130+ // Lazy-load E2B token: the Secret may not have existed when the App CRD event was processed.
131+ // a singleflight coalesces concurrent requests for the same secret into a single K8s API call.
132+ if e .e2bAccessToken == "" && e .e2bSecretName != "" {
133+ token , err , _ := w .tokenLoadGroup .Do (e .e2bSecretName , func () (any , error ) {
134+ return w .loadSecretToken (context .Background (), e .e2bSecretName )
135+ })
136+ if err != nil {
137+ w .logger .Warnf (context .Background (), "App %s: failed to lazy-load E2B access token from secret %q: %s" , appID , e .e2bSecretName , err )
138+ } else if t , ok := token .(string ); t != "" && ok {
139+ e .e2bAccessToken = t
140+ w .apps .Store (appID , e )
141+ w .logger .Infof (context .Background (), "App %s: lazy-loaded E2B access token from secret %q" , appID , e .e2bSecretName )
142+ }
143+ }
144+
123145 return AppInfo {
124146 ActualState : e .state ,
125147 AutoRestartEnabled : e .autoRestartEnabled ,
126148 UpstreamTarget : e .upstreamTarget ,
149+ E2BAccessToken : e .e2bAccessToken ,
127150 }, true
128151}
129152
@@ -194,12 +217,26 @@ func (w *StateWatcher) handleUpsert(ctx context.Context, obj any) {
194217 }
195218 }
196219
220+ var e2bAccessToken string
221+ var e2bSecretName string
222+ if appObj .Spec .Runtime .Backend .Type == BackendTypeE2BSandbox {
223+ e2bSecretName = appObj .Status .E2BSandbox .AccessTokenSecretName
224+ if e2bSecretName != "" {
225+ token , err := w .loadSecretToken (ctx , e2bSecretName )
226+ if err == nil {
227+ e2bAccessToken = token
228+ }
229+ }
230+ }
231+
197232 appID := api .AppID (appObj .Spec .AppID )
198233 w .apps .Store (appID , entry {
199234 k8sName : k8sName ,
200235 state : appObj .Status .CurrentState ,
201236 autoRestartEnabled : autoRestartEnabled ,
202237 upstreamTarget : upstreamTarget ,
238+ e2bAccessToken : e2bAccessToken ,
239+ e2bSecretName : e2bSecretName ,
203240 })
204241 w .logger .Debugf (ctx , "App CRD %q (appID=%s) state updated: actualState=%q autoRestartEnabled=%v upstreamTarget=%v" , k8sName , appID , appObj .Status .CurrentState , autoRestartEnabled , upstreamTarget != nil )
205242}
@@ -229,3 +266,32 @@ func (w *StateWatcher) handleDelete(ctx context.Context, obj any) {
229266 return true
230267 })
231268}
269+
270+ // loadSecretToken fetches a K8s Secret by name and returns the value of the "token" key.
271+ // The dynamic client returns Secret data values as base64-encoded strings.
272+ func (w * StateWatcher ) loadSecretToken (ctx context.Context , secretName string ) (string , error ) {
273+ obj , err := w .client .Resource (SecretGVR ()).Namespace (w .namespace ).Get (ctx , secretName , metav1.GetOptions {})
274+ if err != nil {
275+ return "" , err
276+ }
277+
278+ data , found , err := unstructured .NestedMap (obj .Object , "data" )
279+ if err != nil {
280+ return "" , errors .Errorf ("secret %q: failed to read data field: %s" , secretName , err )
281+ }
282+ if ! found {
283+ return "" , errors .Errorf ("secret %q has no data field" , secretName )
284+ }
285+
286+ token , ok := data ["token" ].(string )
287+ if ! ok || token == "" {
288+ return "" , errors .Errorf ("secret %q has no \" token\" key in data" , secretName )
289+ }
290+
291+ tokenBytes , err := base64 .StdEncoding .DecodeString (token )
292+ if err != nil {
293+ return "" , errors .Errorf ("secret %q: failed to base64-decode token: %s" , secretName , err )
294+ }
295+
296+ return string (tokenBytes ), nil
297+ }
0 commit comments