UNPKG

tilt-ts-core

Version:

A TypeScript implementation of a Tilt-like development tool for Kubernetes live development workflows

1,635 lines (1,626 loc) 137 kB
import * as fs from 'fs'; import * as path5 from 'path'; import { spawn } from 'child_process'; import { logs, SeverityNumber } from '@opentelemetry/api-logs'; import * as watcher from '@parcel/watcher'; import * as crypto from 'crypto'; import { EventEmitter } from 'events'; import fg from 'fast-glob'; import ignore from 'ignore'; import * as fs12 from 'fs/promises'; import { mkdir, writeFile } from 'fs/promises'; import * as YAML from 'yaml'; import { set } from 'es-toolkit/compat'; import chalk from 'chalk'; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) { if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); var __esm = (fn, res) => function __init() { return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; // src/core/image-registry.ts var image_registry_exports = {}; __export(image_registry_exports, { imageRegistry: () => imageRegistry }); var ImageRegistry, imageRegistry; var init_image_registry = __esm({ "src/core/image-registry.ts"() { ImageRegistry = class { images = /* @__PURE__ */ new Map(); stateFile; constructor() { const sessionId = process.env.TILT_TS_SESSION_ID || "default"; this.stateFile = path5.join(process.cwd(), ".tilt-ts", `image-registry-${sessionId}.json`); this.loadState(); } saveState() { const stateDir = path5.dirname(this.stateFile); if (!fs.existsSync(stateDir)) { fs.mkdirSync(stateDir, { recursive: true }); } const serializedImages = []; for (const [key, entry] of this.images) { serializedImages.push([key, entry]); } fs.writeFileSync(this.stateFile, JSON.stringify(serializedImages, null, 2)); } loadState() { if (!fs.existsSync(this.stateFile)) { return; } try { const serializedImages = JSON.parse(fs.readFileSync(this.stateFile, "utf8")); this.images.clear(); for (const [key, entry] of serializedImages || []) { this.images.set(key, entry); } } catch (error) { } } /** * Register a built image with its logical name and live update config */ register(logicalName, builtImage, registryConfig) { this.images.set(logicalName, { builtImage, registryConfig }); this.saveState(); } /** * Get a registered image by logical name */ get(logicalName) { return this.images.get(logicalName); } /** * Get all registered images */ getAll() { return Array.from(this.images.values()); } /** * Check if a logical name is registered */ has(logicalName) { return this.images.has(logicalName); } /** * Clear all registered images (useful for testing) */ clear() { this.images.clear(); } /** * Force reload state from disk (useful when called from different module contexts) */ forceReloadState(sessionId) { this.images.clear(); if (sessionId) { this.stateFile = path5.join(process.cwd(), ".tilt-ts", `image-registry-${sessionId}.json`); } this.loadState(); } /** * Clean up state file (call at session end) */ cleanup() { if (fs.existsSync(this.stateFile)) { fs.unlinkSync(this.stateFile); } } /** * Get all images that have live update configurations */ getLiveUpdateImages() { return this.getAll().filter((entry) => entry.builtImage.live_update && entry.builtImage.live_update.length > 0); } }; imageRegistry = new ImageRegistry(); } }); var Logger = class { logger = logs.getLogger("tilt-ts", "1.0.0"); debug(message, attributes) { this.logger.emit({ severityText: "DEBUG", severityNumber: SeverityNumber.DEBUG, body: message, attributes }); } info(message, attributes) { this.logger.emit({ severityText: "INFO", severityNumber: SeverityNumber.INFO, body: message, attributes }); } warn(message, attributes) { this.logger.emit({ severityText: "WARN", severityNumber: SeverityNumber.WARN, body: message, attributes }); } error(message, attributes) { this.logger.emit({ severityText: "ERROR", severityNumber: SeverityNumber.ERROR, body: message, attributes }); } }; var logger = new Logger(); // src/utils/process.ts async function exec(cmd, opts = {}) { if (!cmd[0]?.includes("kubectl")) { logger.info("Running command", { command: cmd.join(" "), cwd: opts.cwd }); } return new Promise((resolve5, reject) => { const [command, ...args] = cmd; const proc = spawn(command, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env || {} }, stdio: [ opts.stdin === "inherit" ? "inherit" : "ignore", "inherit", "inherit" ] }); proc.on("close", (code) => { if (code !== 0) { reject(new Error(`command failed ${code}: ${cmd.join(" ")}`)); } else { resolve5(); } }); proc.on("error", (err) => { reject(new Error(`command error: ${err.message}`)); }); }); } async function execCapture(cmd, opts = {}) { if (!cmd[0]?.includes("kubectl")) { logger.info("Running command (capture)", { command: cmd.join(" "), cwd: opts.cwd }); } return new Promise((resolve5, reject) => { const [command, ...args] = cmd; const proc = spawn(command, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env || {} }, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; if (proc.stdout) { proc.stdout.on("data", (data) => { stdout += data.toString(); }); } if (proc.stderr) { proc.stderr.on("data", (data) => { stderr += data.toString(); }); } proc.on("close", (code) => { if (code !== 0) { reject(new Error(`command failed ${code}: ${cmd.join(" ")} ${stderr || stdout}`)); } else { resolve5(stdout); } }); proc.on("error", (err) => { reject(new Error(`command error: ${err.message}`)); }); }); } // src/utils/registry.ts function registryRef(registry, image, tag) { const reg = registry || "localhost:36269"; const repo = image.includes("/") ? image : `dev/${image}`; return `${reg}/${repo}:${tag}`; } // src/core/docker.ts init_image_registry(); // src/core/registry.ts var globalRegistry = null; function default_registry(config) { config.clusterUrl = config.clusterUrl ?? config.hostUrl; globalRegistry = config; logger.info("\u{1F3D7}\uFE0F Set default registry configuration", { hostUrl: config.hostUrl, clusterUrl: config.clusterUrl, operation: "default_registry" }); return config; } function get_default_registry() { return globalRegistry; } function reset_default_registry() { globalRegistry = null; logger.info("\u{1F504} Reset default registry configuration", { operation: "reset_default_registry" }); } // src/utils/image-correlation.ts init_image_registry(); async function streamFileToPod(localFilePath, namespace, podName, remotePath, containerName) { return new Promise((resolve5, reject) => { const kubectlArgs = [ "exec", "-i", "-n", namespace, podName, ...containerName ? ["-c", containerName] : [], "--", "tee", remotePath ]; const kubectlProc = spawn("kubectl", kubectlArgs, { stdio: ["pipe", "pipe", "pipe"] }); const readStream = fs.createReadStream(localFilePath); readStream.pipe(kubectlProc.stdin); kubectlProc.stdout.on("data", () => { }); let stderr = ""; kubectlProc.stderr.on("data", (data) => { stderr += data.toString(); }); readStream.on("error", (err) => { kubectlProc.kill(); reject(new Error(`file read error: ${err.message}`)); }); kubectlProc.on("close", (code) => { if (code !== 0) { reject(new Error(`kubectl exec failed ${code}: ${stderr}`)); } else { resolve5(); } }); kubectlProc.on("error", (err) => { reject(new Error(`kubectl exec error: ${err.message}`)); }); }); } function pathIsDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } } function buildWatchEntries(pathsIn) { const absRequested = Array.from(new Set(pathsIn.map((p) => path5.resolve(process.cwd(), p)))); const map = /* @__PURE__ */ new Map(); for (const p of absRequested) { const isDir = pathIsDir(p); const root = isDir ? p : path5.dirname(p); const set2 = map.get(root) ?? /* @__PURE__ */ new Set(); if (!isDir) set2.add(p); map.set(root, set2); } const roots = Array.from(map.keys()).sort((a, b) => a.length - b.length); const kept = []; for (const r of roots) { const filesInRoot = map.get(r); const isDirWatch = filesInRoot.size === 0; const shouldKeep = isDirWatch || !kept.some((k) => r.startsWith(k + path5.sep)); if (shouldKeep) kept.push(r); } return kept.map((r) => ({ root: r, files: map.get(r) })); } async function createTar(root, relFiles, outTarPath) { const fileListPath = path5.join(root, `.devtool-files-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); fs.writeFileSync(fileListPath, relFiles.map((f) => f.replaceAll("\\", "/")).join("\n"), "utf8"); try { await new Promise((resolve5, reject) => { const proc = spawn("tar", ["-C", root, "-cf", outTarPath, "-T", fileListPath], { stdio: ["ignore", "inherit", "inherit"] }); proc.on("close", (code) => { if (code !== 0) { reject(new Error(`tar failed with code ${code}`)); } else { resolve5(); } }); proc.on("error", (err) => { reject(new Error(`tar error: ${err.message}`)); }); }); } finally { fs.unlinkSync(fileListPath); } } function shQ(s) { if (/^[A-Za-z0-9._\-\/:@]+$/.test(s)) return s; return `'${s.replaceAll("'", "'\\''")}'`; } var PodWatcher = class { readyPods = /* @__PURE__ */ new Set(); runningPods = /* @__PURE__ */ new Set(); watchProcess; selector; trackedContainers = /* @__PURE__ */ new Map(); containerEventHandlers = []; /** * Start watching pods for a specific resource selector */ async start(selector) { this.selector = selector; const namespace = selector.namespace || "default"; logger.info("Starting pod watcher", { kind: selector.kind, name: selector.name, namespace, operation: "pod_watcher_start" }); try { const labelSelector = await this.getLabelSelector(selector); logger.info("Pod watcher label selector configured", { labelSelector, operation: "pod_watcher_label_selector" }); this.watchProcess = spawn("kubectl", [ "get", "pods", "-n", namespace, "-l", labelSelector, "--watch=true", "--output-watch-events=true", "-o", "json" ], { stdio: ["ignore", "pipe", "pipe"] }); if (this.watchProcess.stdout) { this.watchProcess.stdout.setEncoding("utf8"); } let buffer = ""; this.watchProcess.stdout?.on("data", (data) => { logger.debug("Pod watcher raw data received", { dataLength: data.length, dataPreview: data.substring(0, 200), operation: "pod_watcher_raw_data" }); buffer += data; const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine) { logger.debug("Processing pod watch line", { linePreview: trimmedLine.substring(0, 150), operation: "pod_watcher_process_line" }); try { const event = JSON.parse(trimmedLine); logger.debug("Pod watch event received", { eventType: event.type, podName: event.object.metadata.name, operation: "pod_watcher_event" }); this.handlePodEvent(event); } catch (e) { logger.warn("Failed to parse pod watch JSON", { error: e instanceof Error ? e.message : String(e), linePreview: trimmedLine.substring(0, 100), operation: "pod_watcher_parse_error" }); } } } }); this.watchProcess.stderr?.on("data", (data) => { logger.warn("Pod watcher stderr output", { stderr: data.toString(), operation: "pod_watcher_stderr" }); }); this.watchProcess.on("exit", (code) => { logger.info("Pod watcher process exited", { exitCode: code, operation: "pod_watcher_exit" }); if (code !== 0) { setTimeout(() => this.restart(), 5e3); } }); await this.getInitialPods(namespace, labelSelector); logger.info("Pod watcher started successfully", { kind: selector.kind, name: selector.name, namespace, labelSelector, operation: "pod_watcher_start" }); } catch (error) { logger.error("Failed to start pod watcher", { error: String(error), selector, operation: "pod_watcher_start" }); throw error; } } /** * Get label selector from the deployment/statefulset/etc */ async getLabelSelector(selector) { try { const resourceJson = await execCapture([ "kubectl", "get", selector.kind.toLowerCase(), selector.name, "-n", selector.namespace || "default", "-o", "json" ]); const resource = JSON.parse(resourceJson); let labels = {}; switch (selector.kind.toLowerCase()) { case "deployment": labels = resource.spec?.template?.metadata?.labels || {}; break; case "statefulset": labels = resource.spec?.template?.metadata?.labels || {}; break; case "daemonset": labels = resource.spec?.template?.metadata?.labels || {}; break; default: labels = resource.spec?.selector?.matchLabels || {}; } if (Object.keys(labels).length === 0) { throw new Error(`No labels found for ${selector.kind}/${selector.name}`); } return Object.entries(labels).map(([k, v]) => `${k}=${v}`).join(","); } catch (error) { logger.error("Failed to get label selector", { error: String(error), selector, operation: "get_label_selector" }); throw error; } } /** * Get initial list of ready pods */ async getInitialPods(namespace, labelSelector) { try { const podsJson = await execCapture([ "kubectl", "get", "pods", "-n", namespace, "-l", labelSelector, "-o", "json" ]); const response = JSON.parse(podsJson); const pods = response.items || []; for (const pod of pods) { if (this.isPodReady(pod)) { this.readyPods.add(pod.metadata.name); logger.debug("Initial ready pod found", { podName: pod.metadata.name, operation: "pod_watcher_initial_ready" }); } } logger.info("Initial pod scan completed", { readyPodsCount: this.readyPods.size, readyPods: Array.from(this.readyPods), operation: "pod_watcher_initial_scan" }); } catch (error) { logger.warn("Failed to get initial pods", { error: String(error), namespace, labelSelector, operation: "get_initial_pods" }); } } /** * Handle pod events from the watch stream */ handlePodEvent(event) { const pod = event.object; const podName = pod.metadata.name; const wasTracked = this.readyPods.has(podName); const isReady = this.isPodReady(pod); const isRunning = this.isPodRunning(pod); logger.debug("Handling pod event", { eventType: event.type, podName, wasTracked, isReady, isRunning, currentCachedPods: Array.from(this.readyPods), currentRunningPods: Array.from(this.runningPods), operation: "pod_watcher_handle_event" }); switch (event.type) { case "ADDED": case "MODIFIED": if (isReady) { if (!this.readyPods.has(podName)) { this.readyPods.add(podName); logger.info("Pod became ready", { podName, operation: "pod_ready" }); } } else { if (this.readyPods.has(podName)) { this.readyPods.delete(podName); logger.info("Pod no longer ready", { podName, operation: "pod_not_ready" }); } } if (isRunning) { if (!this.runningPods.has(podName)) { this.runningPods.add(podName); logger.info("Pod became running", { podName, operation: "pod_running" }); } } else { if (this.runningPods.has(podName)) { this.runningPods.delete(podName); logger.info("Pod no longer running", { podName, operation: "pod_not_running" }); } } this.handleContainerEvents(pod); break; case "DELETED": if (this.readyPods.has(podName)) { this.readyPods.delete(podName); logger.info("Pod deleted from ready cache", { podName, operation: "pod_deleted_ready" }); } else { logger.debug("Pod deleted but was not in ready cache", { podName, operation: "pod_deleted_not_cached" }); } if (this.runningPods.has(podName)) { this.runningPods.delete(podName); logger.info("Pod deleted from running cache", { podName, operation: "pod_deleted_running" }); } this.cleanupContainersForPod(podName); break; } logger.debug("Pod watcher cache state updated", { readyPods: Array.from(this.readyPods), runningPods: Array.from(this.runningPods), operation: "pod_watcher_cache_updated" }); logger.debug("Pod event handled", { event: event.type, pod: podName, ready: isReady, totalReadyPods: this.readyPods.size, operation: "handle_pod_event" }); } /** * Check if a pod is ready for operations */ isPodReady(pod) { const phase = pod.status?.phase; const conditions = pod.status?.conditions || []; const ready = conditions.find((c) => c.type === "Ready")?.status === "True"; const running = phase === "Running"; return running && ready; } /** * Get list of ready pods (instant - no kubectl calls) */ getReadyPods() { return Array.from(this.readyPods); } /** * Check if a pod is truly running and stable (stricter than ready) */ isPodRunning(pod) { const phase = pod.status?.phase; const conditions = pod.status?.conditions || []; const ready = conditions.find((c) => c.type === "Ready")?.status === "True"; const isRunning = phase === "Running"; const notDeleting = !pod.metadata?.deletionTimestamp; const containerStatuses = pod.status?.containerStatuses || []; const allContainersRunning = containerStatuses.every( (status) => status.ready && status.state?.running ); return isRunning && ready && notDeleting && allContainersRunning; } /** * Get list of truly running pods (instant - no kubectl calls) */ getRunningPods() { return Array.from(this.runningPods); } /** * Stop the pod watcher */ stop() { if (this.watchProcess) { logger.info("Killing pod watcher process", { operation: "pod_watcher_killing" }); this.watchProcess.kill("SIGTERM"); setTimeout(() => { if (this.watchProcess && !this.watchProcess.killed) { logger.warn("Force killing pod watcher process", { operation: "pod_watcher_force_kill" }); this.watchProcess.kill("SIGKILL"); } }, 500); this.watchProcess = void 0; } this.readyPods.clear(); logger.info("Pod watcher stopped", { selector: this.selector, operation: "pod_watcher_stop" }); } /** * Restart the pod watcher */ async restart() { logger.info("Restarting pod watcher", { operation: "pod_watcher_restart" }); if (this.selector) { try { await this.start(this.selector); } catch (error) { logger.error("Failed to restart pod watcher", { error: String(error), operation: "pod_watcher_restart_failed" }); setTimeout(() => this.restart(), 1e4); } } } /** * Handle container lifecycle events from pod status */ handleContainerEvents(pod) { const podName = pod.metadata.name; const containerStatuses = pod.status?.containerStatuses || []; for (const status of containerStatuses) { const containerName = status.name; const key = `${podName}:${containerName}`; const existing = this.trackedContainers.get(key); let startTime = Date.now(); if (status.state?.running?.startedAt) { startTime = new Date(status.state.running.startedAt).getTime(); } const container = { podName, containerName, containerID: status.containerID || "", restartCount: status.restartCount || 0, startTime }; if (!existing) { this.trackedContainers.set(key, container); this.notifyContainerEvent({ type: "started", container }); logger.info("New container detected", { podName: container.podName, containerName: container.containerName, containerID: container.containerID.substring(0, 12), startTime: container.startTime }); } else if (container.containerID !== existing.containerID || container.restartCount > existing.restartCount) { this.trackedContainers.set(key, container); this.notifyContainerEvent({ type: "restarted", container }); logger.info("Container restart detected", { podName: container.podName, containerName: container.containerName, oldContainerID: existing.containerID.substring(0, 12), newContainerID: container.containerID.substring(0, 12), oldRestartCount: existing.restartCount, newRestartCount: container.restartCount }); } } } /** * Clean up tracked containers for a deleted pod */ cleanupContainersForPod(podName) { const keysToDelete = []; for (const [key, container] of this.trackedContainers) { if (container.podName === podName) { keysToDelete.push(key); } } for (const key of keysToDelete) { this.trackedContainers.delete(key); } } /** * Notify all container event handlers */ notifyContainerEvent(event) { for (const handler of this.containerEventHandlers) { try { handler(event); } catch (error) { logger.error("Error in container event handler", { error: error instanceof Error ? error.message : String(error) }); } } } /** * Add an event handler for container lifecycle events */ onContainerEvent(handler) { this.containerEventHandlers.push(handler); } /** * Get tracked container info for a specific pod/container */ getTrackedContainer(podName, containerName) { const key = `${podName}:${containerName || "default"}`; return this.trackedContainers.get(key); } /** * Check if a container needs catch-up sync based on our tracking */ needsCatchupSync(podName, containerName) { const tracked = this.getTrackedContainer(podName, containerName); if (!tracked) { return true; } const now = Date.now(); const recentStart = now - tracked.startTime < 3e4; return recentStart; } /** * Get current status for debugging */ getStatus() { return { isWatching: !!this.watchProcess, readyPodCount: this.readyPods.size, readyPods: this.getReadyPods(), runningPodCount: this.runningPods.size, runningPods: this.getRunningPods(), trackedContainerCount: this.trackedContainers.size, selector: this.selector }; } }; var SyncStateManager = class { stateDir; stateFile; state; constructor(projectRoot = process.cwd()) { this.stateDir = path5.join(projectRoot, ".tilt-ts"); this.stateFile = path5.join(this.stateDir, "sync-state.json"); this.state = this.loadState(); } loadState() { try { if (fs.existsSync(this.stateFile)) { const data = fs.readFileSync(this.stateFile, "utf8"); const parsed = JSON.parse(data); logger.info("Loaded sync state", { version: parsed.version, imageCount: Object.keys(parsed.images).length, currentImage: parsed.currentImage }); return parsed; } } catch (error) { logger.warn("Failed to load sync state, starting fresh", { error: error instanceof Error ? error.message : String(error) }); } return { version: "1.0.0", images: {} }; } saveState() { try { if (!fs.existsSync(this.stateDir)) { fs.mkdirSync(this.stateDir, { recursive: true }); } fs.writeFileSync(this.stateFile, JSON.stringify(this.state, null, 2)); logger.info("Saved sync state", { imageCount: Object.keys(this.state.images).length, stateFile: this.stateFile }); } catch (error) { logger.error("Failed to save sync state", { error: error instanceof Error ? error.message : String(error), stateFile: this.stateFile }); } } /** * Track a new Docker image build, resetting sync state for this image */ trackImageBuild(imageTag, imageHash) { const buildTimestamp = Date.now(); this.state.images[imageTag] = { imageTag, imageHash, buildTimestamp, syncedFiles: {}, containers: {} }; this.state.currentImage = imageTag; this.saveState(); logger.info("Tracked new image build", { imageTag, imageHash: imageHash.substring(0, 12), buildTimestamp }); } /** * Track a file that was synced to containers */ trackFileSync(relativePath, localFilePath, sourceDirectory) { logger.debug("trackFileSync - currentImage check", { currentImage: this.state.currentImage }); if (!this.state.currentImage) { logger.debug( "trackFileSync - no current image, skipping file tracking" ); logger.warn("No current image set, cannot track file sync"); return; } const imageState = this.state.images[this.state.currentImage]; if (!imageState) { logger.warn("Current image not found in state", { currentImage: this.state.currentImage }); return; } try { const content = fs.readFileSync(localFilePath); const contentHash = crypto.createHash("sha256").update(content).digest("hex"); const size = content.length; const fileState = { relativePath, contentHash, syncTimestamp: Date.now(), size, sourceDirectory }; imageState.syncedFiles[relativePath] = fileState; logger.info("trackFileSync - added file", { relativePath, totalFiles: Object.keys(imageState.syncedFiles).length }); this.saveState(); logger.info("Tracked file sync", { relativePath, contentHash: contentHash.substring(0, 12), size }); } catch (error) { logger.error("Failed to track file sync", { relativePath, localFilePath, error: error instanceof Error ? error.message : String(error) }); } } /** * Track a container's state for lifecycle detection */ trackContainer(podName, containerID, startTime) { if (!this.state.currentImage) { return; } const imageState = this.state.images[this.state.currentImage]; if (!imageState) { return; } const containerKey = `${podName}:${containerID}`; imageState.containers[containerKey] = { podName, containerID, startTime, lastSyncCheck: Date.now() }; this.saveState(); logger.info("Tracked container", { podName, containerID: containerID.substring(0, 12), startTime }); } /** * Check if a container needs catch-up sync (is new since last sync) */ needsCatchupSync(podName, containerID, startTime) { if (!this.state.currentImage) { return false; } const imageState = this.state.images[this.state.currentImage]; if (!imageState) { return false; } const containerKey = `${podName}:${containerID}`; const existingContainer = imageState.containers[containerKey]; if (!existingContainer) { return true; } if (startTime > existingContainer.startTime) { return true; } return false; } /** * Get all synced files for the current image that need to be re-synced */ getFilesForCatchupSync() { if (!this.state.currentImage) { return []; } const imageState = this.state.images[this.state.currentImage]; if (!imageState) { return []; } return Object.values(imageState.syncedFiles); } /** * Get the current image information */ getCurrentImageState() { if (!this.state.currentImage) { return void 0; } return this.state.images[this.state.currentImage]; } /** * Clear sync state for debugging or reset */ clearState() { this.state = { version: "1.0.0", images: {} }; this.saveState(); logger.info("Cleared sync state"); } }; var CatchupSyncManager = class { syncStateManager; constructor(syncStateManager) { this.syncStateManager = syncStateManager || new SyncStateManager(); } /** * Perform catch-up sync for a container that needs it */ async performCatchupSync(options) { const { namespace, podName, containerName, syncDestinations } = options; logger.info("Starting catch-up sync for container", { podName, containerName, namespace, operation: "catchup_sync_start" }); logger.info("catchup - starting file recovery", { podName, containerName, namespace, operation: "catchup_start" }); const filesToSync = this.syncStateManager.getFilesForCatchupSync(); if (filesToSync.length === 0) { logger.info("catchup - no files to sync", { podName, containerName, namespace, operation: "catchup_no_files" }); return; } logger.info("catchup - files to recover", { podName, containerName, namespace, count: filesToSync.length, operation: "catchup_list_count" }); const existingFiles = []; const projectRoot = process.cwd(); for (const fileState of filesToSync) { const localFilePath = path5.join(projectRoot, fileState.sourceDirectory, fileState.relativePath); try { const fs13 = await import('fs'); await fs13.promises.access(localFilePath, fs13.constants.F_OK); existingFiles.push(fileState); } catch { logger.warn("catchup::skip - file no longer exists, skipping", { podName, containerName, namespace, relativePath: fileState.relativePath, operation: "catchup_skip_missing" }); } } if (existingFiles.length === 0) { logger.info("catchup - no existing files to sync", { podName, containerName, namespace, operation: "catchup_no_existing" }); return; } logger.info("catchup - existing files to recover", { podName, containerName, namespace, existingCount: existingFiles.length, skippedCount: filesToSync.length - existingFiles.length, operation: "catchup_existing_count" }); let syncedCount = 0; let errorCount = 0; const filesByDestination = this.groupFilesByDestination(existingFiles, syncDestinations); for (const [destBase, files] of filesByDestination) { try { await this.ensureDestinationDirectory(namespace, podName, containerName, destBase); for (const fileState of files) { try { await this.syncSingleFile(namespace, podName, containerName, fileState, destBase); syncedCount++; logger.info("catchup::create - file synced", { podName, containerName, namespace, relativePath: fileState.relativePath, operation: "catchup_file_synced" }); } catch (error) { errorCount++; const errorMessage = error instanceof Error ? error.message : String(error); logger.warn("catchup::error - failed to sync file", { podName, containerName, namespace, relativePath: fileState.relativePath, error: errorMessage, operation: "catchup_file_error" }); } } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("catchup::error - failed to prepare destination", { podName, containerName, namespace, destination: destBase, error: errorMessage, operation: "catchup_prepare_dest_error" }); } } if (syncedCount > 0) { logger.info("catchup - recovered files", { podName, containerName, namespace, count: syncedCount, operation: "catchup_recovered_summary" }); } if (errorCount > 0) { logger.warn("catchup - files failed", { podName, containerName, namespace, count: errorCount, operation: "catchup_failed_summary" }); } logger.info("Completed catch-up sync for container", { podName, containerName, syncedCount, errorCount, totalFiles: filesToSync.length, operation: "catchup_sync_complete" }); } /** * Group files by their destination base path */ groupFilesByDestination(files, syncDestinations) { const groups = /* @__PURE__ */ new Map(); for (const file of files) { let destBase = syncDestinations.length > 0 ? syncDestinations[0].dest : "/tmp"; if (!groups.has(destBase)) { groups.set(destBase, []); } groups.get(destBase).push(file); } return groups; } /** * Ensure destination directory exists in the pod with retry for container readiness */ async ensureDestinationDirectory(namespace, podName, containerName, destBase) { const maxRetries = 5; const retryDelay = 1e3; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await exec([ "kubectl", "exec", "-n", namespace, podName, ...containerName ? ["-c", containerName] : [], "--", "mkdir", "-p", destBase ]); return; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (attempt === maxRetries) { throw new Error(`Failed to create directory after ${maxRetries} attempts: ${errorMessage}`); } logger.warn("catchup::retry - mkdir failed, will retry", { podName, containerName, namespace, destination: destBase, attempt, maxRetries, delayMs: retryDelay, error: errorMessage, operation: "catchup_mkdir_retry" }); await new Promise((resolve5) => setTimeout(resolve5, retryDelay)); } } } /** * Sync a single file to the pod */ async syncSingleFile(namespace, podName, containerName, fileState, destBase) { const projectRoot = process.cwd(); const localFilePath = path5.join(projectRoot, fileState.sourceDirectory, fileState.relativePath); logger.debug("catchup::file - resolving local path", { podName, containerName, namespace, relativePath: fileState.relativePath, sourceDirectory: fileState.sourceDirectory, localFilePath, operation: "catchup_file_resolve" }); const destPath = path5.join(destBase, fileState.relativePath); const parentDir = path5.dirname(destPath); if (parentDir !== destBase) { await this.ensureDestinationDirectory(namespace, podName, containerName, parentDir); } const maxRetries = 5; const retryDelay = 1e3; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await streamFileToPod(localFilePath, namespace, podName, destPath, containerName); break; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (attempt === maxRetries) { throw new Error(`Failed to stream file after ${maxRetries} attempts: ${errorMessage}`); } logger.warn("catchup::retry - file stream failed, will retry", { podName, containerName, namespace, relativePath: fileState.relativePath, destPath, attempt, maxRetries, delayMs: retryDelay, error: errorMessage, operation: "catchup_stream_retry" }); await new Promise((resolve5) => setTimeout(resolve5, retryDelay)); } } logger.info("Catch-up synced file to pod", { podName, containerName, namespace, relativePath: fileState.relativePath, destPath, contentHash: fileState.contentHash.substring(0, 12), operation: "catchup_file_synced_detail" }); } /** * Check if a container needs catch-up sync */ needsCatchupSync(podName, containerName) { const currentImage = this.syncStateManager.getCurrentImageState(); logger.debug("catchup::manager::check - current image state presence", { podName, containerName, hasCurrentImage: !!currentImage, operation: "catchup_check_image_presence" }); logger.debug("catchup::manager::check - input pod/container", { podName, containerName, operation: "catchup_check_inputs" }); if (!currentImage) { logger.debug("catchup::manager::check - no current image state", { podName, containerName, operation: "catchup_check_no_image" }); return false; } const syncedFileCount = Object.keys(currentImage.syncedFiles).length; const syncedFileNames = Object.keys(currentImage.syncedFiles); logger.debug("catchup::manager::check - synced files summary", { podName, containerName, count: syncedFileCount, files: syncedFileNames, operation: "catchup_check_synced_summary" }); return syncedFileCount > 0; } }; // src/core/ignore-patterns.ts var DEFAULT_IGNORE_PATTERNS = [ "**/node_modules/**", "**/.git/**", "**/.idea/**", "**/.devtool-*/**", "**/.venv/**", // Python virtual env "**/venv/**", // Alternative venv name "**/__pycache__/**", // Python cache "**/.pytest_cache/**", // Pytest cache "**/site-packages/**" // Direct site-packages ]; var globalIgnorePatterns = [...DEFAULT_IGNORE_PATTERNS]; function setGlobalIgnorePatterns(customPatterns) { globalIgnorePatterns = [...DEFAULT_IGNORE_PATTERNS, ...customPatterns]; } function getGlobalIgnorePatterns() { return [...globalIgnorePatterns]; } function resetIgnorePatterns() { globalIgnorePatterns = [...DEFAULT_IGNORE_PATTERNS]; } function getCustomIgnorePatterns() { return globalIgnorePatterns.slice(DEFAULT_IGNORE_PATTERNS.length); } // src/core/live-update.ts function scanDirectoryRecursively(dirPath) { const foundFiles = []; try { const items = fs.readdirSync(dirPath, { withFileTypes: true }); for (const item of items) { const itemPath = path5.join(dirPath, item.name); if (item.isFile()) { foundFiles.push(itemPath); } else if (item.isDirectory()) { const subFiles = scanDirectoryRecursively(itemPath); foundFiles.push(...subFiles); } } } catch (error) { logger.warn("Failed to scan directory during file discovery", { dirPath, error: error instanceof Error ? error.message : String(error), operation: "scan_directory_error" }); } return foundFiles; } async function batchSyncFilesToPod(files, absSrc, namespace, podName, containerName) { if (files.length === 0) return; const tempDir = process.cwd(); const tarFileName = `tilt-batch-${Date.now()}-${Math.random().toString(36).slice(2)}.tar`; const localTarPath = path5.join(tempDir, tarFileName); const remoteTarPath = `/tmp/${tarFileName}`; try { const relativeFiles = files.map((f) => f.relPath); await createTar(absSrc, relativeFiles, localTarPath); logger.debug("Created tar archive for batch sync", { fileCount: files.length, tarPath: localTarPath, operation: "batch_sync_tar_created" }); await exec([ "kubectl", "cp", localTarPath, `${namespace}/${podName}:${remoteTarPath}`, ...containerName ? ["-c", containerName] : [] ]); logger.debug("Uploaded tar to pod", { podName, remotePath: remoteTarPath, operation: "batch_sync_uploaded" }); const firstDestPath = files[0]?.destPath || ""; const firstRelPath = files[0]?.relPath || ""; const baseDestDir = firstDestPath.replace(firstRelPath, "").replace(/\/$/, ""); await exec([ "kubectl", "exec", "-n", namespace, podName, ...containerName ? ["-c", containerName] : [], "--", "tar", "-xf", remoteTarPath, "-C", baseDestDir ]); logger.info("Batch sync completed successfully", { fileCount: files.length, podName, baseDestDir, operation: "batch_sync_completed" }); await exec([ "kubectl", "exec", "-n", namespace, podName, ...containerName ? ["-c", containerName] : [], "--", "rm", "-f", remoteTarPath ]); logger.debug("Batch synced files", { files: files.map((f) => f.relPath), operation: "batch_sync_files" }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("Batch sync failed", { error: errorMessage, fileCount: files.length, podName, operation: "batch_sync_error" }); throw error; } finally { try { if (fs.existsSync(localTarPath)) { fs.unlinkSync(localTarPath); } } catch (cleanupError) { logger.warn("Failed to cleanup local tar file", { error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), tarPath: localTarPath, operation: "batch_sync_cleanup_failed" }); } } } async function live_update_binding(bind) { const ns = bind.selector.namespace || "default"; const containerName = bind.selector.container; const BATCH_SYNC_THRESHOLD = parseInt(process.env.TILT_BATCH_SYNC_THRESHOLD || "2"); const syncStateManager = new SyncStateManager(); const catchupSyncManager = new CatchupSyncManager(syncStateManager); const podWatcher = new PodWatcher(); await podWatcher.start(bind.selector); podWatcher.onContainerEvent(async (event) => { if (event.type === "restarted") { logger.info("Container restarted", { podName: event.container.podName, containerID: event.container.containerID.substring(0, 12), operation: "container_restart" }); } else if (event.type === "started") { logger.info("New container detected", { podName: event.container.podName, containerID: event.container.containerID.substring(0, 12), operation: "container_start" }); const podName = event.container.podName; const podWatcherNeedsCatchup = podWatcher.needsCatchupSync(podName, containerName); const catchupManagerNeedsCatchup = catchupSyncManager.needsCatchupSync(podName, containerName); if (podWatcherNeedsCatchup && catchupManagerNeedsCatchup) { logger.info("Initiating catch-up sync for new container", { podName, operation: "catchup_sync_start" }); const syncDestinations = syncSteps.map((s) => ({ src: s.src, dest: s.dest })); try { await catchupSyncManager.performCatchupSync({ namespace: ns, podName, containerName, syncDestinations }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("Catch-up sync failed", { podName, error: errorMessage, operation: "catchup_sync_error" }); logger.error("Failed to perform catch-up sync for new container", { podName, containerName, error: errorMessage }); } } } }); async function currentPods() { const pods = podWatcher.getRunningPods(); if (pods.length > 0) { logger.info("Found running pods for operations (cached)", { runningPodCount: pods.length, runningPods: pods, operation: "current_pods_cached" }); } else { logger.warn("No running pods found for operations (cached)", { operation: "current_pods_cached" }); } return pods; } const syncSteps = bind.steps.filter((s) => s.type === "sync"); const runSteps = bind.steps.filter((s) => s.type === "run"); const watchTargets = [ ...syncSteps.map((s) => s.src), ...runSteps.flatMap((r) => r.whenFilesChanged || []) ]; const entries = buildWatchEntries(watchTargets); for (const e of entries) { logger.info("Setting up file watcher", { root: e.root, fileCount: e.files.size, isDirectoryWatch: e.files.size === 0, absoluteRoot: path5.resolve(e.root), operation: "watch_setup" }); } const projectRoot = process.cwd(); const changedSet = /* @__PURE__ */ new Set(); let timer = null; const createdDirs = /* @__PURE__ */ new Set(); async function flush() { if (changedSet.size === 0) return; const changes = Array.from(changedSet.values()); changedSet.clear(); let pods; try { pods = await currentPods(); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("Failed to get pods", { operation: "get_pods_error" }); logger.warn("Failed to get current pods, skipping this sync cycle", { error: errorMessage.replace(/^command failed \d+: /, ""), changeCount: changes.length }); return; } if (pods.length === 0) { logger.warn("No running pods found", { operation: "no_pods_warning" }); logger.info("No running pods available, skipping sync cycle", { changeCount: changes.length }); return; } for (const s of syncSteps) { const absSrc = path5.resolve(projectRoot, s.src); const allRelativeChanges = changes.filter((p) => { const rel = path5.relative(absSrc, p); return rel && !rel.startsWith("..") && rel !== "."; }).map((p) => path5.relative(absSrc, p)); const rels = allRelativeChanges.filter((p) => { const fullPath = path5.join(absSrc, p); return fs.existsSync(fullPath); }); const deletedFiles = allRelativeChanges.filter((p) => { const fullPath = path5.join(absSrc, p); return !fs.existsSync(fullPath); }); logger.debug("Filtering file changes for sync step", { srcPath: s.src, absSrc, totalChanges: changes.length, allChanges: changes.map((p) => path5.relative(projectRoot, p)), allRelativeChanges, matchingFiles: rels, deletedFiles, operation: "sync_filter_debug" }); logger.info("Filtered files for sync", { totalChanges: changes.length, absSrc, matchingFiles: rels, deletedFiles, allChanges: changes }); if (deletedFiles.length > 0) { for (const pod of pods) { for (const deletedFile of deletedFiles) { const destPath = `${s.dest}/${deletedFile}`; logger.info("Deleti