github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/controllers/core/extensionrepo/reconciler.go (about) 1 package extensionrepo 2 3 import ( 4 "context" 5 "fmt" 6 "os" 7 "path/filepath" 8 "strings" 9 "sync" 10 "time" 11 12 apierrors "k8s.io/apimachinery/pkg/api/errors" 13 "k8s.io/apimachinery/pkg/types" 14 ctrl "sigs.k8s.io/controller-runtime" 15 "sigs.k8s.io/controller-runtime/pkg/builder" 16 ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" 17 "sigs.k8s.io/controller-runtime/pkg/reconcile" 18 19 "github.com/tilt-dev/go-get" 20 "github.com/tilt-dev/tilt/internal/controllers/apicmp" 21 "github.com/tilt-dev/tilt/internal/store" 22 "github.com/tilt-dev/tilt/internal/xdg" 23 "github.com/tilt-dev/tilt/pkg/apis" 24 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 25 "github.com/tilt-dev/tilt/pkg/logger" 26 ) 27 28 const tiltModulesRelDir = "tilt_modules" 29 30 type Downloader interface { 31 DestinationPath(pkg string) string 32 Download(pkg string) (string, error) 33 HeadRef(pkg string) (string, error) 34 RefSync(pkg string, ref string) error 35 } 36 37 type Reconciler struct { 38 ctrlClient ctrlclient.Client 39 st store.RStore 40 dlr Downloader 41 mu sync.Mutex 42 43 repoStates map[types.NamespacedName]*repoState 44 } 45 46 func (r *Reconciler) CreateBuilder(mgr ctrl.Manager) (*builder.Builder, error) { 47 b := ctrl.NewControllerManagedBy(mgr). 48 For(&v1alpha1.ExtensionRepo{}) 49 50 return b, nil 51 } 52 53 func NewReconciler(ctrlClient ctrlclient.Client, st store.RStore, base xdg.Base) (*Reconciler, error) { 54 dlrPath, err := base.DataFile(tiltModulesRelDir) 55 if err != nil { 56 return nil, fmt.Errorf("creating extensionrepo controller: %v", err) 57 } 58 return &Reconciler{ 59 ctrlClient: ctrlClient, 60 st: st, 61 dlr: get.NewDownloader(dlrPath), 62 repoStates: make(map[types.NamespacedName]*repoState), 63 }, nil 64 } 65 66 // Downloads extension repos. 67 func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { 68 r.mu.Lock() 69 defer r.mu.Unlock() 70 71 nn := request.NamespacedName 72 73 var repo v1alpha1.ExtensionRepo 74 err := r.ctrlClient.Get(ctx, nn, &repo) 75 if err != nil && !apierrors.IsNotFound(err) { 76 return ctrl.Result{}, err 77 } 78 79 ctx = store.MustObjectLogHandler(ctx, r.st, &repo) 80 81 result, state, err := r.apply(ctx, nn, &repo) 82 if err != nil { 83 return ctrl.Result{}, err 84 } 85 if state == nil { 86 return ctrl.Result{}, nil 87 } 88 89 err = r.maybeUpdateStatus(ctx, &repo, state) 90 if err != nil { 91 return ctrl.Result{}, err 92 } 93 return result, nil 94 } 95 96 // Reconciles the extension repo without reading or writing from the API server. 97 // Returns the resolved status. 98 // Exposed for outside callers. 99 func (r *Reconciler) ForceApply(ctx context.Context, repo *v1alpha1.ExtensionRepo) v1alpha1.ExtensionRepoStatus { 100 r.mu.Lock() 101 defer r.mu.Unlock() 102 nn := types.NamespacedName{Name: repo.Name, Namespace: repo.Namespace} 103 _, state, err := r.apply(ctx, nn, repo) 104 if err != nil { 105 return v1alpha1.ExtensionRepoStatus{Error: err.Error()} 106 } 107 if state == nil { 108 return v1alpha1.ExtensionRepoStatus{Error: "internal error: could not reconcile"} 109 } 110 return state.status 111 } 112 113 // Reconciles the extension repo without reading or writing from the API server. 114 // Caller must hold the mutex. 115 // Returns a nil state if the repo is being deleted. 116 // Returns an error if the reconcile should be retried. 117 func (r *Reconciler) apply(ctx context.Context, nn types.NamespacedName, repo *v1alpha1.ExtensionRepo) (ctrl.Result, *repoState, error) { 118 isDelete := repo.Name == "" || !repo.DeletionTimestamp.IsZero() 119 needsCleanup := isDelete 120 121 // If the spec has changed, clear the current repo state. 122 state, ok := r.repoStates[nn] 123 if ok && !apicmp.DeepEqual(state.spec, repo.Spec) { 124 needsCleanup = true 125 } 126 127 if needsCleanup { 128 // If a repo is deleted, delete it on disk. 129 // 130 // This is simple, but not accurate. 131 // 1) It will garbage collect too aggressively if two extension repo 132 // objects point to the same URL. 133 // 2) If we "miss" a delete event, the repo will never get cleaned up. 134 // 135 // A "real" implementation would use the on-disk repos as the source of 136 // truth, and garbage collect ones with no remaining refs. 137 if state != nil && state.lastSuccessfulDestPath != "" { 138 err := os.RemoveAll(state.lastSuccessfulDestPath) 139 if err != nil && !os.IsNotExist(err) { 140 return ctrl.Result{}, nil, err 141 } 142 } 143 delete(r.repoStates, nn) 144 } 145 146 if isDelete { 147 return ctrl.Result{}, nil, nil 148 } 149 150 state, ok = r.repoStates[nn] 151 if !ok { 152 state = &repoState{spec: repo.Spec} 153 r.repoStates[nn] = state 154 } 155 156 // Keep track of the Result in case it contains Requeue instructions. 157 var result ctrl.Result 158 if strings.HasPrefix(repo.Spec.URL, "file://") { 159 r.reconcileFileRepo(ctx, state, strings.TrimPrefix(repo.Spec.URL, "file://")) 160 } else { 161 // Check that the URL is valid. 162 importPath, err := getDownloaderImportPath(repo) 163 if err != nil { 164 state.status = v1alpha1.ExtensionRepoStatus{Error: fmt.Sprintf("invalid: %v", err)} 165 } else { 166 result = r.reconcileDownloaderRepo(ctx, state, importPath) 167 } 168 } 169 return result, state, nil 170 } 171 172 // Reconcile a repo that lives on disk, and shouldn't otherwise be modified. 173 func (r *Reconciler) reconcileFileRepo(ctx context.Context, state *repoState, filePath string) { 174 if state.spec.Ref != "" { 175 state.status = v1alpha1.ExtensionRepoStatus{Error: "spec.ref not supported on file:// repos"} 176 return 177 } 178 179 if !filepath.IsAbs(filePath) { 180 state.status = v1alpha1.ExtensionRepoStatus{ 181 Error: fmt.Sprintf("file paths must be absolute. Url: %s", state.spec.URL), 182 } 183 return 184 } 185 186 info, err := os.Stat(filePath) 187 if err != nil { 188 state.status = v1alpha1.ExtensionRepoStatus{Error: fmt.Sprintf("loading: %v", err)} 189 return 190 } 191 192 if !info.IsDir() { 193 state.status = v1alpha1.ExtensionRepoStatus{Error: "loading: not a directory"} 194 return 195 } 196 197 timeFetched := apis.NewTime(info.ModTime()) 198 state.status = v1alpha1.ExtensionRepoStatus{LastFetchedAt: timeFetched, Path: filePath} 199 } 200 201 // Reconcile a repo that we need to fetch remotely, and store 202 // under ~/.tilt-dev. 203 func (r *Reconciler) reconcileDownloaderRepo(ctx context.Context, state *repoState, importPath string) reconcile.Result { 204 getDlr, ok := r.dlr.(*get.Downloader) 205 if ok { 206 getDlr.Stderr = logger.Get(ctx).Writer(logger.InfoLvl) 207 } 208 209 destPath := r.dlr.DestinationPath(importPath) 210 info, err := os.Stat(destPath) 211 if err != nil && !os.IsNotExist(err) { 212 state.status = v1alpha1.ExtensionRepoStatus{Error: fmt.Sprintf("loading download destination: %v", err)} 213 return ctrl.Result{} 214 } 215 216 // If the directory exists and has already been fetched successfully during this session, 217 // no reconciliation is needed. 218 exists := err == nil 219 if exists && state.lastSuccessfulDestPath != "" { 220 return ctrl.Result{} 221 } 222 223 lastFetch := state.lastFetch 224 lastBackoff := state.backoff 225 if time.Since(lastFetch) < lastBackoff { 226 // If we're already in the middle of a backoff period, requeue. 227 return ctrl.Result{RequeueAfter: lastBackoff} 228 } 229 230 state.lastFetch = time.Now() 231 232 needsDownload := true 233 pinnedToVersion := state.spec.Ref != "" && state.spec.Ref != "HEAD" 234 if exists && pinnedToVersion { 235 // If an explicit ref is specified, we assume there's no reason to pull a new version. 236 // 237 // TODO(nick): Should we try to support cases where the ref can change server-side? 238 // e.g., a "stable" tag. 239 err := r.dlr.RefSync(importPath, state.spec.Ref) 240 if err == nil { 241 needsDownload = false 242 } else { 243 // TODO(nick): The more efficient thing to do here would be to 244 // checkout the main branch and pull. But I don't think this case will 245 // happen very often, so it's safer to delete and re-download. 246 err = os.RemoveAll(destPath) 247 if err != nil { 248 state.status = v1alpha1.ExtensionRepoStatus{ 249 Error: fmt.Sprintf("clearing old extension repo at %s: %v", destPath, err), 250 } 251 return ctrl.Result{} 252 } 253 } 254 } 255 256 staleReason := "" 257 if needsDownload { 258 _, err = r.dlr.Download(importPath) 259 if err != nil { 260 if !exists { 261 // Delete any partial state. 262 _ = os.RemoveAll(destPath) 263 264 backoff := state.nextBackoff() 265 backoffMsg := fmt.Sprintf("download error: waiting %s before retrying. Original error: %v", backoff, err) 266 state.status = v1alpha1.ExtensionRepoStatus{Error: backoffMsg} 267 return ctrl.Result{RequeueAfter: backoff} 268 } 269 staleReason = err.Error() 270 } 271 272 info, err = os.Stat(destPath) 273 if err != nil { 274 state.status = v1alpha1.ExtensionRepoStatus{Error: fmt.Sprintf("verifying download destination: %v", err)} 275 return ctrl.Result{} 276 } 277 278 if state.spec.Ref != "" { 279 err := r.dlr.RefSync(importPath, state.spec.Ref) 280 if err != nil { 281 state.status = v1alpha1.ExtensionRepoStatus{Error: fmt.Sprintf("sync to ref %s: %v", state.spec.Ref, err)} 282 return ctrl.Result{} 283 } 284 } 285 } 286 287 ref, err := r.dlr.HeadRef(importPath) 288 if err != nil { 289 state.status = v1alpha1.ExtensionRepoStatus{Error: fmt.Sprintf("determining head: %v", err)} 290 return ctrl.Result{} 291 } 292 293 // Update the status. 294 state.backoff = 0 295 state.lastSuccessfulDestPath = destPath 296 297 timeFetched := apis.NewTime(info.ModTime()) 298 state.status = v1alpha1.ExtensionRepoStatus{ 299 LastFetchedAt: timeFetched, 300 Path: destPath, 301 CheckoutRef: ref, 302 StaleReason: staleReason, 303 } 304 return ctrl.Result{} 305 } 306 307 // Loosely inspired by controllerutil's Update status algorithm. 308 func (r *Reconciler) maybeUpdateStatus(ctx context.Context, repo *v1alpha1.ExtensionRepo, state *repoState) error { 309 if apicmp.DeepEqual(repo.Status, state.status) { 310 return nil 311 } 312 313 oldStaleReason := repo.Status.StaleReason 314 newStaleReason := state.status.StaleReason 315 oldError := repo.Status.Error 316 newError := state.status.Error 317 update := repo.DeepCopy() 318 update.Status = *(state.status.DeepCopy()) 319 320 err := r.ctrlClient.Status().Update(ctx, update) 321 if err != nil { 322 return err 323 } 324 325 if newStaleReason != "" && newStaleReason != oldStaleReason { 326 logger.Get(ctx).Infof("extensionrepo %s: may be stale: %s", repo.Name, newStaleReason) 327 } 328 329 if newError != "" && oldError != newError { 330 logger.Get(ctx).Errorf("extensionrepo %s: %s", repo.Name, newError) 331 } 332 333 return err 334 } 335 336 func getDownloaderImportPath(repo *v1alpha1.ExtensionRepo) (string, error) { 337 // TODO(nick): Add file URLs 338 url := repo.Spec.URL 339 if strings.HasPrefix(url, "https://") { 340 return strings.TrimPrefix(url, "https://"), nil 341 } 342 if strings.HasPrefix(url, "http://") { 343 return strings.TrimPrefix(url, "http://"), nil 344 } 345 return "", fmt.Errorf("URL must start with 'https://': %v", url) 346 } 347 348 type repoState struct { 349 spec v1alpha1.ExtensionRepoSpec 350 lastFetch time.Time 351 backoff time.Duration 352 lastSuccessfulDestPath string 353 354 status v1alpha1.ExtensionRepoStatus 355 } 356 357 // Step up the backoff after an error. 358 func (s *repoState) nextBackoff() time.Duration { 359 backoff := s.backoff 360 if backoff == 0 { 361 backoff = 5 * time.Second 362 } else { 363 backoff = 2 * backoff 364 } 365 s.backoff = backoff 366 return backoff 367 }