Skip to content

Commit 65484ed

Browse files
committed
feat: Adding mutable attribute for clones to force copy instead of hard links
1 parent 7b1bb75 commit 65484ed

17 files changed

Lines changed: 208 additions & 46 deletions

File tree

docs/src/content/docs/03-features/02-stacks/03-explicit.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,8 @@ When `update_source_with_cas = true` is set:
412412

413413
Consumers do not set `update_source_with_cas` themselves. When the `cas` experiment is enabled and the source is remote, Terragrunt uses the CAS path automatically. The attribute only has effect inside catalog files, where it flags nested `source` attributes for rewriting.
414414

415+
`unit` and `stack` blocks also accept a `mutable` attribute. When `mutable = true`, the unit's or stack's content under `.terragrunt-stack` is copied from the CAS store instead of hardlinked. The default is `false`, which lets CAS hardlink files for speed and deduplication. Set this when you intend to edit files in `.terragrunt-stack` directly: hardlinked files share an inode with the shared store, so editing them in place would corrupt it.
416+
415417
Given the catalog files above, and an ordinary consumer stack like:
416418

417419
```hcl

docs/src/content/docs/04-reference/01-hcl/02-blocks.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ The `terraform` block supports the following arguments:
6969

7070
*Note that if you wish to exclude files from being copied from a terraform module source, you should use the [before_hook](/features/units/hooks) feature.*
7171

72+
- `mutable` (attribute): When `true`, content fetched into `.terragrunt-cache` is copied from the
73+
[content-addressable storage (CAS)](/features/caching/cas) instead of hardlinked. The default is `false`,
74+
which lets CAS hardlink files from its shared store for speed and deduplication. Set this to `true` when you
75+
intend to edit files inside `.terragrunt-cache` directly: hardlinked files share an inode with the CAS store,
76+
so editing them in place would corrupt the store. The flag has no effect when CAS is not used to fetch the
77+
source; the standard download path already produces an independent copy.
78+
7279
- `copy_terraform_lock_file` (attribute): In certain use cases, you don't want to check the terraform provider lock
7380
file into your source repository from your working directory as described in
7481
[Lock File Handling](/reference/lock-files). This attribute allows you to disable the copy

internal/cas/cas.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ type CloneOptions struct {
4747
// If zero, CAS falls back to its configured clone depth (default shallow depth 1).
4848
// Set to -1 for full history (Terragrunt omits --depth; git rejects --depth 0).
4949
Depth int
50+
51+
// Mutable, when true, copies blobs into the target directory instead of
52+
// hardlinking them from the CAS store. The destination tree becomes safe
53+
// to mutate without corrupting the shared store.
54+
Mutable bool
5055
}
5156

5257
// CAS clones a git repository using content-addressable storage.
@@ -191,7 +196,12 @@ func (c *CAS) Clone(ctx context.Context, l log.Logger, opts *CloneOptions, url s
191196
return err
192197
}
193198

194-
return LinkTree(childCtx, c.blobStore, c.treeStore, tree, targetDir)
199+
var linkOpts []LinkTreeOption
200+
if opts.Mutable {
201+
linkOpts = append(linkOpts, WithForceCopy())
202+
}
203+
204+
return LinkTree(childCtx, c.blobStore, c.treeStore, tree, targetDir, linkOpts...)
195205
})
196206
}
197207

internal/cas/content.go

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -40,49 +40,72 @@ func NewContent(store *Store) *Content {
4040
}
4141
}
4242

43-
// Link creates a hard link from the store to the target path
44-
func (c *Content) Link(ctx context.Context, hash, targetPath string) error {
43+
// LinkOption configures a single Content.Link call.
44+
type LinkOption func(*linkOpts)
45+
46+
type linkOpts struct {
47+
forceCopy bool
48+
}
49+
50+
// WithLinkForceCopy makes Link copy the file from the store into the target
51+
// path instead of creating a hard link, so the destination is safe to mutate
52+
// without affecting the shared store.
53+
func WithLinkForceCopy() LinkOption {
54+
return func(o *linkOpts) { o.forceCopy = true }
55+
}
56+
57+
// Link creates a hard link from the store to the target path. When
58+
// WithLinkForceCopy is supplied it skips the hard link and copies the file,
59+
// producing a writable, independent inode at the target.
60+
func (c *Content) Link(ctx context.Context, hash, targetPath string, opts ...LinkOption) error {
61+
var o linkOpts
62+
for _, opt := range opts {
63+
opt(&o)
64+
}
65+
4566
return telemetry.TelemeterFromContext(ctx).Collect(ctx, "cas_link", map[string]any{
46-
"hash": hash,
47-
"path": targetPath,
67+
"hash": hash,
68+
"path": targetPath,
69+
"force_copy": o.forceCopy,
4870
}, func(childCtx context.Context) error {
4971
sourcePath := c.getPath(hash)
5072

51-
// Try to create hard link directly (most efficient path)
52-
if err := vfs.Link(c.fs, sourcePath, targetPath); err != nil {
53-
// Check if it's because target already exists
54-
if os.IsExist(err) {
73+
if !o.forceCopy {
74+
// Try to create hard link directly (most efficient path)
75+
if err := vfs.Link(c.fs, sourcePath, targetPath); err == nil {
76+
return nil
77+
} else if os.IsExist(err) {
5578
// File already exists, which is fine
5679
return nil
5780
}
81+
// Fall through to copy on link failure.
82+
}
5883

59-
// If hard link fails for other reasons, try to copy the file
60-
data, readErr := vfs.ReadFile(c.fs, sourcePath)
61-
if readErr != nil {
62-
return &WrappedError{
63-
Op: "read_source",
64-
Path: sourcePath,
65-
Err: ErrReadFile,
66-
}
84+
data, readErr := vfs.ReadFile(c.fs, sourcePath)
85+
if readErr != nil {
86+
return &WrappedError{
87+
Op: "read_source",
88+
Path: sourcePath,
89+
Err: ErrReadFile,
6790
}
91+
}
6892

69-
// Write to temporary file first
70-
tempPath := targetPath + ".tmp"
71-
if err := vfs.WriteFile(c.fs, tempPath, data, RegularFilePerms); err != nil {
72-
return &WrappedError{
73-
Op: "write_target",
74-
Path: tempPath,
75-
Err: err,
76-
}
93+
// Write to temporary file first
94+
tempPath := targetPath + ".tmp"
95+
if err := vfs.WriteFile(c.fs, tempPath, data, RegularFilePerms); err != nil {
96+
return &WrappedError{
97+
Op: "write_target",
98+
Path: tempPath,
99+
Err: err,
77100
}
101+
}
78102

79-
// Atomic rename to final path
80-
if err := c.fs.Rename(tempPath, targetPath); err != nil {
81-
return &WrappedError{
82-
Op: "rename_target",
83-
Path: tempPath,
84-
Err: err,
85-
}
103+
// Atomic rename to final path
104+
if err := c.fs.Rename(tempPath, targetPath); err != nil {
105+
return &WrappedError{
106+
Op: "rename_target",
107+
Path: tempPath,
108+
Err: err,
86109
}
87110
}
88111

internal/cas/content_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,45 @@ func TestContent_Link(t *testing.T) {
155155
assert.True(t, os.SameFile(sourceInfo, targetInfo), "expected hard link (same inode)")
156156
})
157157

158+
t.Run("force copy creates independent inode on real filesystem", func(t *testing.T) {
159+
t.Parallel()
160+
161+
osFs := vfs.NewOSFS()
162+
storeDir := t.TempDir()
163+
targetDir := t.TempDir()
164+
store := cas.NewStore(storeDir).WithFS(osFs)
165+
166+
content := cas.NewContent(store)
167+
testHash := testHashValue
168+
testData := []byte("test content")
169+
170+
err := content.Store(l, testHash, testData)
171+
require.NoError(t, err)
172+
173+
targetPath := filepath.Join(targetDir, "test.txt")
174+
err = content.Link(t.Context(), testHash, targetPath, cas.WithLinkForceCopy())
175+
require.NoError(t, err)
176+
177+
sourcePath := filepath.Join(storeDir, testHash[:2], testHash)
178+
sourceInfo, err := os.Stat(sourcePath)
179+
require.NoError(t, err)
180+
targetInfo, err := os.Stat(targetPath)
181+
require.NoError(t, err)
182+
assert.False(t, os.SameFile(sourceInfo, targetInfo), "expected independent inode (copy, not hard link)")
183+
184+
copied, err := os.ReadFile(targetPath)
185+
require.NoError(t, err)
186+
assert.Equal(t, testData, copied)
187+
188+
// The destination must be writable so callers can mutate it without
189+
// touching the shared store.
190+
require.NoError(t, os.WriteFile(targetPath, []byte("mutated"), 0644))
191+
192+
stored, err := os.ReadFile(sourcePath)
193+
require.NoError(t, err)
194+
assert.Equal(t, testData, stored, "store blob must not change when target is mutated")
195+
})
196+
158197
t.Run("link to existing file", func(t *testing.T) {
159198
t.Parallel()
160199

internal/cas/local.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ const DefaultLocalHashAlgorithm = HashSHA256
2121

2222
// StoreLocalDirectory persists all content from a local source directory into the CAS
2323
// and then links the persisted files to the target directory.
24-
func (c *CAS) StoreLocalDirectory(ctx context.Context, l log.Logger, sourceDir, targetDir string) error {
24+
func (c *CAS) StoreLocalDirectory(
25+
ctx context.Context,
26+
l log.Logger,
27+
sourceDir, targetDir string,
28+
opts ...LinkTreeOption,
29+
) error {
2530
hash, treeData, err := c.buildLocalTree(sourceDir, DefaultLocalHashAlgorithm)
2631
if err != nil {
2732
return fmt.Errorf("failed to hash local directory %s: %w", sourceDir, err)
@@ -36,7 +41,7 @@ func (c *CAS) StoreLocalDirectory(ctx context.Context, l log.Logger, sourceDir,
3641
return fmt.Errorf("failed to parse local tree: %w", err)
3742
}
3843

39-
return LinkTree(ctx, c.blobStore, c.treeStore, tree, targetDir)
44+
return LinkTree(ctx, c.blobStore, c.treeStore, tree, targetDir, opts...)
4045
}
4146

4247
// ComputeLocalRootHash walks dir in deterministic (lexical) order and produces a

internal/cas/protocol.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,13 @@ func FormatCASRefWithSubdir(hash, subdir string) string {
103103

104104
// MaterializeTree reads a tree from the CAS store and links its contents to the destination directory.
105105
// It tries the synth store first, then falls back to the git tree store.
106-
func (c *CAS) MaterializeTree(ctx context.Context, l log.Logger, hash string, dest string) error {
106+
func (c *CAS) MaterializeTree(
107+
ctx context.Context,
108+
l log.Logger,
109+
hash string,
110+
dest string,
111+
opts ...LinkTreeOption,
112+
) error {
107113
var treeData []byte
108114

109115
var treeStoreUsed *Store
@@ -139,5 +145,5 @@ func (c *CAS) MaterializeTree(ctx context.Context, l log.Logger, hash string, de
139145
return fmt.Errorf("failed to parse CAS tree %s: %w", hash, err)
140146
}
141147

142-
return LinkTree(ctx, c.blobStore, treeStoreUsed, tree, dest)
148+
return LinkTree(ctx, c.blobStore, treeStoreUsed, tree, dest, opts...)
143149
}

internal/cas/tree.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,43 @@ import (
1010
"golang.org/x/sync/errgroup"
1111
)
1212

13+
// LinkTreeOption configures a LinkTree call.
14+
type LinkTreeOption func(*linkTreeOpts)
15+
16+
type linkTreeOpts struct {
17+
forceCopy bool
18+
}
19+
20+
// WithForceCopy makes LinkTree copy blobs from the CAS store into the target
21+
// directory instead of hardlinking them. The destination tree becomes safe to
22+
// mutate without affecting the shared store, at the cost of extra I/O.
23+
func WithForceCopy() LinkTreeOption {
24+
return func(o *linkTreeOpts) { o.forceCopy = true }
25+
}
26+
1327
// LinkTree writes the tree to a target directory.
1428
// blobStore is used to resolve blob entries, treeStore is used to resolve subtree entries.
15-
func LinkTree(ctx context.Context, blobStore *Store, treeStore *Store, t *git.Tree, targetDir string) error {
29+
func LinkTree(
30+
ctx context.Context,
31+
blobStore *Store,
32+
treeStore *Store,
33+
t *git.Tree,
34+
targetDir string,
35+
opts ...LinkTreeOption,
36+
) error {
37+
var o linkTreeOpts
38+
for _, opt := range opts {
39+
opt(&o)
40+
}
41+
1642
blobContent := NewContent(blobStore)
1743
treeContent := NewContent(treeStore)
1844

45+
var linkOpts []LinkOption
46+
if o.forceCopy {
47+
linkOpts = append(linkOpts, WithLinkForceCopy())
48+
}
49+
1950
dirsToCreate := make(map[string]struct{}, len(t.Entries()))
2051

2152
type workItem struct {
@@ -79,7 +110,7 @@ func LinkTree(ctx context.Context, blobStore *Store, treeStore *Store, t *git.Tr
79110
g.Go(func() error {
80111
switch work.itemType {
81112
case "link":
82-
err := blobContent.Link(ctx, work.entry.Hash, work.path)
113+
err := blobContent.Link(ctx, work.entry.Hash, work.path, linkOpts...)
83114
if err != nil {
84115
return fmt.Errorf("link blob %s: %w", work.path, err)
85116
}
@@ -94,7 +125,7 @@ func LinkTree(ctx context.Context, blobStore *Store, treeStore *Store, t *git.Tr
94125
return fmt.Errorf("parse tree %s: %w", work.entry.Hash, err)
95126
}
96127

97-
err = LinkTree(ctx, blobStore, treeStore, subTree, work.path)
128+
err = LinkTree(ctx, blobStore, treeStore, subTree, work.path, opts...)
98129
if err != nil {
99130
return fmt.Errorf("link subtree %s: %w", work.path, err)
100131
}

internal/getter/casgetter.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ func NewCASGetter(l log.Logger, c *cas.CAS, opts *cas.CloneOptions) *CASGetter {
4545
func (g *CASGetter) Get(ctx context.Context, req *getter.Request) error {
4646
if req.Copy {
4747
// Local directory: persist to CAS and link.
48-
return g.CAS.StoreLocalDirectory(ctx, g.Logger, req.Src, req.Dst)
48+
var linkOpts []cas.LinkTreeOption
49+
if g.Opts != nil && g.Opts.Mutable {
50+
linkOpts = append(linkOpts, cas.WithForceCopy())
51+
}
52+
53+
return g.CAS.StoreLocalDirectory(ctx, g.Logger, req.Src, req.Dst, linkOpts...)
4954
}
5055

5156
ref := ""

internal/getter/casprotocol.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import (
1414
// CASProtocolGetter resolves cas::<algorithm>:<hash> references by
1515
// materializing the referenced tree from the CAS store.
1616
type CASProtocolGetter struct {
17-
CAS *cas.CAS
18-
Logger log.Logger
17+
CAS *cas.CAS
18+
Logger log.Logger
19+
Mutable bool
1920
}
2021

2122
// NewCASProtocolGetter creates a new CASProtocolGetter.

0 commit comments

Comments
 (0)