Skip to content

Commit 78f46e6

Browse files
authored
[deckhouse-cli] remove partial artefacts (#359)
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
1 parent ec9bc0f commit 78f46e6

11 files changed

Lines changed: 842 additions & 96 deletions

File tree

internal/mirror/chunked/chunk_writer.go

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,31 @@ package chunked
1818

1919
import (
2020
"bytes"
21+
"errors"
2122
"fmt"
2223
"os"
2324
"path/filepath"
2425
)
2526

27+
// tmpChunkSuffix is appended to in-flight chunk files so an aborted run leaves
28+
// behind a clearly-named partial file that can be cleaned up later, rather
29+
// than a half-written .chunk that looks like a finished artifact.
30+
const tmpChunkSuffix = ".tmp"
31+
2632
type FileWriter struct {
2733
chunkSize int64
2834
chunkIndex int
2935

3036
workingDir string
3137
baseFileName string
3238
activeChunk *os.File
39+
40+
// writtenChunks tracks paths of all chunk files we have created so far
41+
// (relative to workingDir, with the .tmp suffix still attached). On
42+
// Finalize they are renamed to drop the suffix; on Cleanup they are
43+
// removed. Used to guarantee no half-finished artifacts survive a
44+
// failed/interrupted run.
45+
writtenChunks []string
3346
}
3447

3548
func NewChunkedFileWriter(chunkSize int64, dirPath, baseFileName string) *FileWriter {
@@ -87,10 +100,46 @@ func (c *FileWriter) Write(p []byte) (int, error) {
87100
}
88101
}
89102

103+
// Close flushes and closes the currently-active chunk. It does NOT promote
104+
// the .tmp chunk paths to their final names: callers must invoke Finalize for
105+
// that, or Cleanup to discard everything. This split lets the caller decide
106+
// after-the-fact whether the produced bundle is valid (e.g. only after Pack
107+
// returned without error) and avoids the previous behavior where a Ctrl+C
108+
// during packing left behind ready-looking .chunk files.
90109
func (c *FileWriter) Close() error {
91110
return c.closeActiveChunk()
92111
}
93112

113+
// Finalize promotes every successfully-written chunk from its temporary path
114+
// (<base>.NNNN.chunk.tmp) to the final path (<base>.NNNN.chunk). Must be
115+
// called only after Close and only on a successful pack.
116+
func (c *FileWriter) Finalize() error {
117+
var errs []error
118+
for _, tmpRelPath := range c.writtenChunks {
119+
tmp := filepath.Join(c.workingDir, tmpRelPath)
120+
final := filepath.Join(c.workingDir, finalChunkName(tmpRelPath))
121+
if err := os.Rename(tmp, final); err != nil {
122+
errs = append(errs, fmt.Errorf("rename %s -> %s: %w", tmp, final, err))
123+
}
124+
}
125+
c.writtenChunks = nil
126+
return errors.Join(errs...)
127+
}
128+
129+
// Cleanup removes any temporary chunk files this writer has created. Safe to
130+
// call multiple times. Use this when the operation failed or was cancelled so
131+
// no partial artifacts leak into the user's bundle directory.
132+
func (c *FileWriter) Cleanup() {
133+
if c.activeChunk != nil {
134+
_ = c.activeChunk.Close()
135+
c.activeChunk = nil
136+
}
137+
for _, tmpRelPath := range c.writtenChunks {
138+
_ = os.Remove(filepath.Join(c.workingDir, tmpRelPath))
139+
}
140+
c.writtenChunks = nil
141+
}
142+
94143
func (c *FileWriter) swapActiveChunk() error {
95144
if c.activeChunk != nil {
96145
if err := c.closeActiveChunk(); err != nil {
@@ -99,12 +148,14 @@ func (c *FileWriter) swapActiveChunk() error {
99148
c.chunkIndex++
100149
}
101150

102-
newChunk, err := os.Create(filepath.Join(c.workingDir, fmt.Sprintf("%s.%04d.chunk", c.baseFileName, c.chunkIndex)))
151+
tmpName := fmt.Sprintf("%s.%04d.chunk%s", c.baseFileName, c.chunkIndex, tmpChunkSuffix)
152+
newChunk, err := os.Create(filepath.Join(c.workingDir, tmpName))
103153
if err != nil {
104154
return fmt.Errorf("Create new chunk file: %w", err)
105155
}
106156

107157
c.activeChunk = newChunk
158+
c.writtenChunks = append(c.writtenChunks, tmpName)
108159
return nil
109160
}
110161

@@ -116,6 +167,15 @@ func (c *FileWriter) closeActiveChunk() error {
116167
if err := c.activeChunk.Close(); err != nil {
117168
return fmt.Errorf("Close chunk: %w", err)
118169
}
170+
c.activeChunk = nil
119171
}
120172
return nil
121173
}
174+
175+
// finalChunkName strips the temporary suffix from a chunk file name.
176+
func finalChunkName(tmpName string) string {
177+
if len(tmpName) > len(tmpChunkSuffix) && tmpName[len(tmpName)-len(tmpChunkSuffix):] == tmpChunkSuffix {
178+
return tmpName[:len(tmpName)-len(tmpChunkSuffix)]
179+
}
180+
return tmpName
181+
}

internal/mirror/chunked/chunk_writer_test.go

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,30 +29,78 @@ import (
2929
)
3030

3131
func TestChunkedFileWriterHappyPath(t *testing.T) {
32-
workingDir := filepath.Join(os.TempDir(), "chunk_test")
33-
require.NoError(t, os.MkdirAll(workingDir, 0o777))
34-
t.Cleanup(func() {
35-
_ = os.RemoveAll(workingDir)
36-
})
32+
workingDir := t.TempDir()
3733

3834
const testDatasetSize, chunkSize = 10 * 1024 * 1024, 3 * 1024 * 1024
3935
sourceFile := make([]byte, testDatasetSize)
4036
bytesGenerated, err := rand.Reader.Read(sourceFile)
4137
require.NoError(t, err)
4238
require.Equal(t, testDatasetSize, bytesGenerated)
4339

40+
w := NewChunkedFileWriter(chunkSize, workingDir, "d8.tar")
4441
bytesWritten, err := io.CopyBuffer(
45-
NewChunkedFileWriter(chunkSize, workingDir, "d8.tar"),
42+
w,
4643
bytes.NewReader(sourceFile),
4744
make([]byte, 512*1024),
4845
)
4946
require.NoError(t, err)
5047
require.Equal(t, int64(bytesGenerated), bytesWritten)
48+
require.NoError(t, w.Close())
49+
require.NoError(t, w.Finalize())
5150

5251
validateSizes(t, workingDir, testDatasetSize, chunkSize)
5352
compareHashes(t, sourceFile, testDatasetSize, workingDir)
5453
}
5554

55+
// TestChunkedFileWriterCleanupRemovesPartialChunks documents the contract
56+
// that callers rely on to keep the bundle directory clean after an
57+
// interrupted pack: every chunk file written so far must disappear from disk
58+
// when Cleanup is invoked, and crucially no file with the final .chunk name
59+
// must ever appear unless Finalize was called.
60+
func TestChunkedFileWriterCleanupRemovesPartialChunks(t *testing.T) {
61+
workingDir := t.TempDir()
62+
63+
w := NewChunkedFileWriter(1024, workingDir, "module-foo.tar")
64+
_, err := w.Write(bytes.Repeat([]byte("a"), 4096))
65+
require.NoError(t, err)
66+
67+
entries, err := os.ReadDir(workingDir)
68+
require.NoError(t, err)
69+
for _, e := range entries {
70+
require.True(t, hasSuffix(e.Name(), tmpChunkSuffix),
71+
"chunk %q should still be staged with %q suffix before Finalize", e.Name(), tmpChunkSuffix)
72+
}
73+
74+
w.Cleanup()
75+
76+
entries, err = os.ReadDir(workingDir)
77+
require.NoError(t, err)
78+
require.Empty(t, entries, "Cleanup must remove every staged chunk")
79+
}
80+
81+
// TestChunkedFileWriterFinalizePromotesAllChunks verifies the full lifecycle:
82+
// staged .tmp chunks must turn into their final .chunk names after Finalize.
83+
func TestChunkedFileWriterFinalizePromotesAllChunks(t *testing.T) {
84+
workingDir := t.TempDir()
85+
86+
w := NewChunkedFileWriter(1024, workingDir, "module-foo.tar")
87+
_, err := w.Write(bytes.Repeat([]byte("a"), 4096))
88+
require.NoError(t, err)
89+
require.NoError(t, w.Close())
90+
require.NoError(t, w.Finalize())
91+
92+
entries, err := os.ReadDir(workingDir)
93+
require.NoError(t, err)
94+
for _, e := range entries {
95+
require.Equal(t, ".chunk", filepath.Ext(e.Name()),
96+
"chunk %q must lose the staging suffix after Finalize", e.Name())
97+
}
98+
}
99+
100+
func hasSuffix(name, suffix string) bool {
101+
return len(name) >= len(suffix) && name[len(name)-len(suffix):] == suffix
102+
}
103+
56104
func compareHashes(t *testing.T, sourceFile []byte, testDatasetSize int, workingDir string) {
57105
t.Helper()
58106

internal/mirror/installer/installer.go

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,12 @@ import (
2121
"fmt"
2222
"io"
2323
"log/slog"
24-
"os"
2524
"path/filepath"
2625
"time"
2726

2827
dkplog "github.com/deckhouse/deckhouse/pkg/log"
2928

30-
"github.com/deckhouse/deckhouse-cli/internal/mirror/chunked"
29+
"github.com/deckhouse/deckhouse-cli/internal/mirror/pack"
3130
"github.com/deckhouse/deckhouse-cli/internal/mirror/puller"
3231
"github.com/deckhouse/deckhouse-cli/pkg/libmirror/bundle"
3332
"github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log"
@@ -178,28 +177,10 @@ func (svc *Service) pullInstaller(ctx context.Context) error {
178177
return fmt.Errorf("pull installer: %w", err)
179178
}
180179

181-
if err := logger.Process("Pack Deckhouse images into platform.tar", func() error {
182-
bundleChunkSize := svc.options.BundleChunkSize
183-
bundleDir := svc.options.BundleDir
184-
185-
var installer io.Writer = chunked.NewChunkedFileWriter(
186-
bundleChunkSize,
187-
bundleDir,
188-
"installer.tar",
189-
)
190-
191-
if bundleChunkSize == 0 {
192-
installer, err = os.Create(filepath.Join(bundleDir, "installer.tar"))
193-
if err != nil {
194-
return fmt.Errorf("create installer.tar: %w", err)
195-
}
196-
}
197-
198-
if err := bundle.Pack(context.Background(), svc.layout.workingDir, installer); err != nil {
199-
return fmt.Errorf("pack installer.tar: %w", err)
200-
}
201-
202-
return nil
180+
if err := logger.Process("Pack Deckhouse images into installer.tar", func() error {
181+
return pack.Bundle(ctx, svc.options.BundleDir, "installer.tar", svc.options.BundleChunkSize, func(w io.Writer) error {
182+
return bundle.Pack(ctx, svc.layout.workingDir, w)
183+
})
203184
}); err != nil {
204185
return err
205186
}

internal/mirror/modules/modules.go

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323
"errors"
2424
"fmt"
2525
"io"
26-
"os"
2726
"path/filepath"
2827
"regexp"
2928
"strings"
@@ -35,7 +34,7 @@ import (
3534
"github.com/deckhouse/deckhouse/pkg/registry/client"
3635

3736
"github.com/deckhouse/deckhouse-cli/internal"
38-
"github.com/deckhouse/deckhouse-cli/internal/mirror/chunked"
37+
"github.com/deckhouse/deckhouse-cli/internal/mirror/pack"
3938
"github.com/deckhouse/deckhouse-cli/internal/mirror/puller"
4039
"github.com/deckhouse/deckhouse-cli/pkg/libmirror/bundle"
4140
"github.com/deckhouse/deckhouse-cli/pkg/libmirror/layouts"
@@ -237,11 +236,27 @@ func (svc *Service) pullModules(ctx context.Context) error {
237236
processName = "Pull Extra Images"
238237
}
239238

239+
// pullCancelled is set when the user interrupts mid-pull. In that case we
240+
// still want the post-processing + packing phase to finalize whatever was
241+
// successfully downloaded so far, rather than throw it all away and leave
242+
// the user with nothing for a multi-hour pull.
243+
pullCancelled := false
240244
err = logger.Process(processName, func() error {
241245
for i, module := range filteredModules {
246+
if err := ctx.Err(); err != nil {
247+
logger.Warnf("Pull cancelled; %d/%d modules attempted, will pack already-downloaded modules", i, len(filteredModules))
248+
pullCancelled = true
249+
return nil
250+
}
251+
242252
logger.Infof("[%d/%d] Processing module: %s", i+1, len(filteredModules), module.name)
243253

244254
if err := svc.pullSingleModule(ctx, module); err != nil {
255+
if isContextErr(err) {
256+
logger.Warnf("Pull of module %s cancelled, will pack already-downloaded modules", module.name)
257+
pullCancelled = true
258+
return nil
259+
}
245260
return fmt.Errorf("pull module %s: %w", module.name, err)
246261
}
247262
}
@@ -256,6 +271,16 @@ func (svc *Service) pullModules(ctx context.Context) error {
256271
return nil
257272
}
258273

274+
// Decouple the post-pull phase from the cancellation context so that the
275+
// last bit of packing finalizes for the modules that *did* download.
276+
// Without this, a Ctrl+C during pull would still surface as ctx.Canceled
277+
// inside bundle.PackWithPrefix and we would discard a multi-GB download
278+
// that was already on disk.
279+
postCtx := ctx
280+
if pullCancelled {
281+
postCtx = context.WithoutCancel(ctx)
282+
}
283+
259284
err = logger.Process("Processing modules image indexes", func() error {
260285
for _, l := range svc.layout.AsList() {
261286
err = layouts.SortIndexManifests(l)
@@ -279,13 +304,19 @@ func (svc *Service) pullModules(ctx context.Context) error {
279304
}
280305

281306
// Pack each module into separate tar
282-
if err := svc.packModules(filteredModules); err != nil {
307+
if err := svc.packModules(postCtx, filteredModules); err != nil {
283308
return err
284309
}
285310

286311
return nil
287312
}
288313

314+
// isContextErr reports whether err is one of the context cancellation errors,
315+
// either directly or wrapped.
316+
func isContextErr(err error) bool {
317+
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
318+
}
319+
289320
func (svc *Service) pullSingleModule(ctx context.Context, module moduleData) error {
290321
downloadList := NewImageDownloadList(filepath.Join(svc.rootURL, "modules", module.name))
291322
svc.modulesDownloadList.list[module.name] = downloadList
@@ -879,13 +910,19 @@ func (svc *Service) applyChannelAliases(moduleName string) error {
879910
return nil
880911
}
881912

882-
func (svc *Service) packModules(modules []moduleData) error {
913+
func (svc *Service) packModules(ctx context.Context, modules []moduleData) error {
883914
logger := svc.userLogger
884915

885916
bundleDir := svc.options.BundleDir
886917
bundleChunkSize := svc.options.BundleChunkSize
887918

888919
for _, module := range modules {
920+
// Honor cancellation between modules so a Ctrl+C during the pack
921+
// phase doesn't keep producing more (potentially useless) tars.
922+
if err := ctx.Err(); err != nil {
923+
return err
924+
}
925+
889926
pkgName := "module-" + module.name + ".tar"
890927

891928
if err := logger.Process(fmt.Sprintf("Pack %s", pkgName), func() error {
@@ -899,24 +936,14 @@ func (svc *Service) packModules(modules []moduleData) error {
899936
return nil
900937
}
901938

902-
var pkg io.Writer = chunked.NewChunkedFileWriter(bundleChunkSize, bundleDir, pkgName)
903-
if bundleChunkSize == 0 {
904-
f, err := os.Create(filepath.Join(bundleDir, pkgName))
905-
if err != nil {
906-
return fmt.Errorf("create %s: %w", pkgName, err)
907-
}
908-
pkg = f
909-
}
910-
911939
// Pack from the module's working directory with prefix to create correct registry structure.
912940
// This ensures the tar contains paths like "modules/<name>/index.json" instead of just "index.json".
913941
moduleDir := filepath.Join(svc.layout.workingDir, module.name)
914942
tarPrefix := filepath.Join("modules", module.name)
915-
if err := bundle.PackWithPrefix(context.Background(), moduleDir, tarPrefix, pkg); err != nil {
916-
return fmt.Errorf("pack module %s: %w", pkgName, err)
917-
}
918943

919-
return nil
944+
return pack.Bundle(ctx, bundleDir, pkgName, bundleChunkSize, func(w io.Writer) error {
945+
return bundle.PackWithPrefix(ctx, moduleDir, tarPrefix, w)
946+
})
920947
}); err != nil {
921948
return err
922949
}

0 commit comments

Comments
 (0)