@@ -64,7 +64,7 @@ func (h *TfeHandler) GetPlan(c echo.Context) error {
6464 ID : plan .RunID ,
6565 },
6666 }
67-
67+
6868 // Only include resource counts when plan is finished
6969 // If we send HasChanges:false before the plan completes, Terraform CLI
7070 // will think there's nothing to apply and won't prompt for confirmation!
@@ -105,16 +105,29 @@ func (h *TfeHandler) GetPlanLogs(c echo.Context) error {
105105 return c .JSON (http .StatusNotFound , map [string ]string {"error" : "plan not found" })
106106 }
107107
108- // Read logs from chunked S3 objects
108+ // Read logs from chunked S3 objects (fixed 2KB chunks)
109109 // Chunks are stored as plans/{planID}/chunks/00000001.log, 00000002.log, etc.
110+ const chunkSize = 2 * 1024 // Must match writer's chunk size
111+
112+ // Determine which chunk contains the requested offset to avoid re-downloading
113+ // data the client already has.
114+ startChunk := 1
115+ if offsetInt > 1 { // offset includes STX byte at offset 0
116+ logOffset := offsetInt - 1
117+ startChunk = int (logOffset / chunkSize ) + 1
118+ }
119+
120+ // Number of bytes before the first chunk we fetch (used to map offsets)
121+ bytesBefore := int64 (chunkSize * (startChunk - 1 ))
122+
110123 var logText string
111- chunkIndex := 1
124+ chunkIndex := startChunk
112125 var fullLogs strings.Builder
113-
126+
114127 for {
115128 chunkKey := fmt .Sprintf ("plans/%s/chunks/%08d.log" , planID , chunkIndex )
116129 logData , err := h .blobStore .DownloadBlob (ctx , chunkKey )
117-
130+
118131 if err != nil {
119132 // Chunk doesn't exist - check if plan is still running
120133 if plan .Status == "finished" || plan .Status == "errored" {
@@ -124,22 +137,26 @@ func (h *TfeHandler) GetPlanLogs(c echo.Context) error {
124137 // Plan still running, this chunk doesn't exist yet
125138 break
126139 }
127-
140+
141+ // Keep chunks at full 2048 bytes (don't trim nulls) for correct offset math
128142 fullLogs .Write (logData )
129143 chunkIndex ++
130144 }
131-
145+
132146 logText = fullLogs .String ()
133-
134- // If no chunks exist yet, generate default logs based on status
135- if logText == "" {
147+
148+ // NOW trim all null bytes from the result (after offset calculation is done)
149+ logText = strings .TrimRight (logText , "\x00 " )
150+
151+ // If no chunks exist yet, generate default logs based on status (only on first request)
152+ if logText == "" && offsetInt == 0 {
136153 logText = generateDefaultPlanLogs (plan )
137154 }
138155
139156 // Handle offset for streaming with proper byte accounting
140157 // Stream format: [STX at offset 0][logText at offset 1+][ETX at offset 1+len(logText)]
141158 var responseData []byte
142-
159+
143160 if offsetInt == 0 {
144161 // First request: send STX + current logs
145162 responseData = append ([]byte {0x02 }, []byte (logText )... )
@@ -149,20 +166,26 @@ func (h *TfeHandler) GetPlanLogs(c echo.Context) error {
149166 }
150167 } else {
151168 // Client already received STX (1 byte at offset 0)
152- // Map stream offset to logText offset: streamOffset=1 → logText[0]
153- logOffset := offsetInt - 1
154-
169+ // Map stream offset to logText offset:
170+ // - stream offset 0 = STX
171+ // - stream offset 1 = first byte of full logs
172+ // We only fetched chunks starting at startChunk, so subtract bytesBefore.
173+ logOffset := offsetInt - 1 - bytesBefore
174+ if logOffset < 0 {
175+ logOffset = 0
176+ }
177+
155178 if logOffset < int64 (len (logText )) {
156179 // Send remaining log text
157180 responseData = []byte (logText [logOffset :])
158- fmt .Printf ("📤 PLAN LOGS at offset=%d: sending %d bytes (logText[%d:])\n " ,
181+ fmt .Printf ("📤 PLAN LOGS at offset=%d: sending %d bytes (logText[%d:])\n " ,
159182 offsetInt , len (responseData ), logOffset )
160- } else if logOffset == int64 ( len ( logText )) && plan .Status == "finished " {
161- // All logs sent, send ETX
183+ } else if plan . Status == "finished" || plan .Status == "errored " {
184+ // All logs sent, send ETX to stop polling
162185 responseData = []byte {0x03 }
163186 fmt .Printf ("📤 Sending ETX (End of Text) for plan %s - logs complete\n " , planID )
164187 } else {
165- // Waiting for more logs or already sent ETX
188+ // Waiting for more logs
166189 responseData = []byte {}
167190 fmt .Printf ("📤 PLAN LOGS at offset=%d: no new data (waiting or complete)\n " , offsetInt )
168191 }
@@ -206,7 +229,7 @@ func (h *TfeHandler) GetPlanJSONOutput(c echo.Context) error {
206229 // Create dummy resource changes based on our counts
207230 // The CLI checks if this array has entries to decide whether to prompt
208231 resourceChanges := make ([]interface {}, 0 )
209-
232+
210233 // Add placeholder entries for additions
211234 for i := 0 ; i < plan .ResourceAdditions ; i ++ {
212235 resourceChanges = append (resourceChanges , map [string ]interface {}{
@@ -215,7 +238,7 @@ func (h *TfeHandler) GetPlanJSONOutput(c echo.Context) error {
215238 },
216239 })
217240 }
218-
241+
219242 // Add placeholder entries for changes
220243 for i := 0 ; i < plan .ResourceChanges ; i ++ {
221244 resourceChanges = append (resourceChanges , map [string ]interface {}{
@@ -224,7 +247,7 @@ func (h *TfeHandler) GetPlanJSONOutput(c echo.Context) error {
224247 },
225248 })
226249 }
227-
250+
228251 // Add placeholder entries for destructions
229252 for i := 0 ; i < plan .ResourceDestructions ; i ++ {
230253 resourceChanges = append (resourceChanges , map [string ]interface {}{
@@ -233,7 +256,7 @@ func (h *TfeHandler) GetPlanJSONOutput(c echo.Context) error {
233256 },
234257 })
235258 }
236-
259+
237260 jsonPlan ["resource_changes" ] = resourceChanges
238261 }
239262
@@ -246,7 +269,7 @@ func generateDefaultPlanLogs(plan *domain.TFEPlan) string {
246269 // Don't show resource counts in logs until plan is finished
247270 // Terraform CLI parses the logs to determine if changes exist!
248271 if plan .Status == "finished" {
249- return fmt .Sprintf (`Terraform used the selected providers to generate the following execution plan.
272+ return fmt .Sprintf (`Terraform used the selected providers to generate the following execution plan.
250273Resource actions are indicated with the following symbols:
251274 + create
252275 - destroy
@@ -260,4 +283,3 @@ Plan: %d to add, %d to change, %d to destroy.
260283 // The CLI will keep polling until it gets real content.
261284 return ""
262285}
263-
0 commit comments