Skip to content

Commit 20c2d03

Browse files
committed
Implement llb.Symlink
* Add file.symlink.create capability and wire it up * Run codegen for new FileActionSymlink Message * Add Symlink test * Add user/group ownership and timestamps to symlink ** Symlinks have user/group ownership that are independent of those of the target file; in linux, the ownership of the symlink itself is only checked when the link resides in a directory with the sticky bit set and the link is the subject of removal or renaming. The sticky bit prevents files in the directory from being deleted or renamed by non-owners (members of the group that owns the file may not delete the file; the user must own the file). In addition to user/group restrictions, linux symlinks have timestamps that are independent of the timestamps on the target file. * Expose symlink options to `llb` package * Add symlink integration test * Use tar exporter for tests ** Using the local exporter causes the files to be exported with the permissions of the user who does the exporting, instead of retaining their file permissions from within the container. Using the tar exporter instead preserves the permissions until they can be checked. * Change symlink fields to `oldpath` and `newpath` ** Also run `make generated-files` * Fix typo * Add doc strings to exported `llb` identifiers * Remove `requiresLinux` from integration test * Revert "Remove `requiresLinux` from integration test" * Add fixes to please the linter * testFileOpSymlink: check that symlink is created * Address comments for FileOp llb test * This commit also fixes a couple of linter complaints. * Add check for symlink type in tar header * Address PR review nit Signed-off-by: Peter Engelbert <[email protected]>
1 parent ca25771 commit 20c2d03

File tree

13 files changed

+1036
-194
lines changed

13 files changed

+1036
-194
lines changed

client/client_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){
222222
testLayerLimitOnMounts,
223223
testFrontendVerifyPlatforms,
224224
testRunValidExitCodes,
225+
testFileOpSymlink,
225226
}
226227

227228
func TestIntegration(t *testing.T) {
@@ -2426,6 +2427,95 @@ func testOCILayoutPlatformSource(t *testing.T, sb integration.Sandbox) {
24262427
}
24272428
}
24282429

2430+
func testFileOpSymlink(t *testing.T, sb integration.Sandbox) {
2431+
requiresLinux(t)
2432+
2433+
const (
2434+
fileOwner = 7777
2435+
fileGroup = 8888
2436+
linkOwner = 1111
2437+
linkGroup = 2222
2438+
2439+
dummyTimestamp = 42
2440+
)
2441+
2442+
dummyTime := time.Unix(dummyTimestamp, 0)
2443+
2444+
c, err := New(sb.Context(), sb.Address())
2445+
require.NoError(t, err)
2446+
defer c.Close()
2447+
2448+
st := llb.Scratch().
2449+
File(llb.Mkdir("/foo", 0700).Mkfile("bar", 0600, []byte("contents"), llb.ChownOpt{
2450+
User: &llb.UserOpt{
2451+
UID: fileOwner,
2452+
},
2453+
Group: &llb.UserOpt{
2454+
UID: fileGroup,
2455+
},
2456+
})).
2457+
File(llb.Symlink("bar", "/baz", llb.WithCreatedTime(dummyTime), llb.ChownOpt{
2458+
User: &llb.UserOpt{
2459+
UID: linkOwner,
2460+
},
2461+
Group: &llb.UserOpt{
2462+
UID: linkGroup,
2463+
},
2464+
}))
2465+
2466+
def, err := st.Marshal(sb.Context())
2467+
require.NoError(t, err)
2468+
2469+
destDir := t.TempDir()
2470+
2471+
out := filepath.Join(destDir, "out.tar")
2472+
outW, err := os.Create(out)
2473+
require.NoError(t, err)
2474+
defer outW.Close()
2475+
2476+
_, err = c.Solve(sb.Context(), def, SolveOpt{
2477+
Exports: []ExportEntry{
2478+
{
2479+
Type: ExporterTar,
2480+
Output: fixedWriteCloser(outW),
2481+
},
2482+
},
2483+
}, nil)
2484+
require.NoError(t, err)
2485+
2486+
dt, err := os.ReadFile(out)
2487+
require.NoError(t, err)
2488+
m, err := testutil.ReadTarToMap(dt, false)
2489+
require.NoError(t, err)
2490+
2491+
entry, ok := m["bar"]
2492+
require.True(t, ok)
2493+
2494+
dt = entry.Data
2495+
header := entry.Header
2496+
require.NoError(t, err)
2497+
2498+
require.Equal(t, []byte("contents"), dt)
2499+
require.Equal(t, fileOwner, header.Uid)
2500+
require.Equal(t, fileGroup, header.Gid)
2501+
2502+
entry, ok = m["baz"]
2503+
require.Equal(t, true, ok)
2504+
2505+
header = entry.Header
2506+
// ensure it is a symlink
2507+
require.Equal(t, tar.TypeSymlink, rune(header.Typeflag))
2508+
// ensure it is a symlink to the proper location
2509+
require.Equal(t, "bar", header.Linkname)
2510+
2511+
// make sure it was chowned properly
2512+
require.Equal(t, linkOwner, header.Uid)
2513+
require.Equal(t, linkGroup, header.Gid)
2514+
2515+
// ensure it was timestamped properly
2516+
require.Equal(t, dummyTime, header.ModTime)
2517+
}
2518+
24292519
func testFileOpRmWildcard(t *testing.T, sb integration.Sandbox) {
24302520
requiresLinux(t)
24312521
c, err := New(sb.Context(), sb.Address())

client/llb/fileop.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ func (fa *FileAction) Mkfile(p string, m os.FileMode, dt []byte, opt ...MkfileOp
8585
return a
8686
}
8787

88+
// Symlink creates a symlink at `newpath` that points to `oldpath`
89+
func (fa *FileAction) Symlink(oldpath, newpath string, opt ...SymlinkOption) *FileAction {
90+
a := Symlink(oldpath, newpath, opt...)
91+
a.prev = fa
92+
return a
93+
}
94+
8895
func (fa *FileAction) Rm(p string, opt ...RmOption) *FileAction {
8996
a := Rm(p, opt...)
9097
a.prev = fa
@@ -193,6 +200,7 @@ type ChownOption interface {
193200
MkdirOption
194201
MkfileOption
195202
CopyOption
203+
SymlinkOption
196204
}
197205

198206
type mkdirOptionFunc func(*MkdirInfo)
@@ -290,6 +298,10 @@ func (co ChownOpt) SetCopyOption(mi *CopyInfo) {
290298
mi.ChownOpt = &co
291299
}
292300

301+
func (co ChownOpt) SetSymlinkOption(si *SymlinkInfo) {
302+
si.ChownOpt = &co
303+
}
304+
293305
func (co *ChownOpt) marshal(base pb.InputIndex) *pb.ChownOpt {
294306
if co == nil {
295307
return nil
@@ -337,6 +349,57 @@ func Mkfile(p string, m os.FileMode, dt []byte, opts ...MkfileOption) *FileActio
337349
}
338350
}
339351

352+
// SymlinkInfo is the modifiable options used to create symlinks
353+
type SymlinkInfo struct {
354+
ChownOpt *ChownOpt
355+
CreatedTime *time.Time
356+
}
357+
358+
func (si *SymlinkInfo) SetSymlinkOption(si2 *SymlinkInfo) {
359+
*si2 = *si
360+
}
361+
362+
type SymlinkOption interface {
363+
SetSymlinkOption(*SymlinkInfo)
364+
}
365+
366+
// Symlink creates a symlink at `newpath` that points to `oldpath`
367+
func Symlink(oldpath, newpath string, opts ...SymlinkOption) *FileAction {
368+
var si SymlinkInfo
369+
for _, o := range opts {
370+
o.SetSymlinkOption(&si)
371+
}
372+
373+
return &FileAction{
374+
action: &fileActionSymlink{
375+
oldpath: oldpath,
376+
newpath: newpath,
377+
info: si,
378+
},
379+
}
380+
}
381+
382+
type fileActionSymlink struct {
383+
oldpath string
384+
newpath string
385+
info SymlinkInfo
386+
}
387+
388+
func (s *fileActionSymlink) addCaps(f *FileOp) {
389+
addCap(&f.constraints, pb.CapFileSymlinkCreate)
390+
}
391+
392+
func (s *fileActionSymlink) toProtoAction(_ context.Context, _ string, base pb.InputIndex) (pb.IsFileAction, error) {
393+
return &pb.FileAction_Symlink{
394+
Symlink: &pb.FileActionSymlink{
395+
Oldpath: s.oldpath,
396+
Newpath: s.newpath,
397+
Owner: s.info.ChownOpt.marshal(base),
398+
Timestamp: marshalTime(s.info.CreatedTime),
399+
},
400+
}, nil
401+
}
402+
340403
type MkfileOption interface {
341404
SetMkfileOption(*MkfileInfo)
342405
}
@@ -606,6 +669,10 @@ func (c CreatedTime) SetMkfileOption(mi *MkfileInfo) {
606669
mi.CreatedTime = (*time.Time)(&c)
607670
}
608671

672+
func (c CreatedTime) SetSymlinkOption(si *SymlinkInfo) {
673+
si.CreatedTime = (*time.Time)(&c)
674+
}
675+
609676
func (c CreatedTime) SetCopyOption(mi *CopyInfo) {
610677
mi.CreatedTime = (*time.Time)(&c)
611678
}

client/llb/fileop_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,67 @@ func TestFileMkfile(t *testing.T) {
179179
require.Equal(t, int64(-1), mkdir.Timestamp)
180180
}
181181

182+
func TestFileSymlink(t *testing.T) {
183+
t.Parallel()
184+
185+
st := Image("foo").Dir("/src").File(
186+
Mkdir("dir", 0o755).
187+
Symlink("/src/dir", "/srcdir").
188+
Mkfile("/srcdir/file", 0700, []byte("asdfjkl;")).
189+
Symlink("dir/file", "/srcdirfile").
190+
Mkdir("/src/dir/subdir", 0o755).
191+
Symlink("/src/dir/subdir", "/src/dir/subdir/nested"))
192+
193+
const numOps = 2
194+
const numActions = 6
195+
def, err := st.Marshal(context.TODO())
196+
197+
require.NoError(t, err)
198+
199+
m, arr := parseDef(t, def.Def)
200+
require.Equal(t, numOps+1, len(arr))
201+
202+
dgst, idx := last(t, arr)
203+
require.Equal(t, 0, idx)
204+
require.Equal(t, m[dgst], arr[numOps-1])
205+
206+
fileOpNode := arr[1]
207+
fileOp := fileOpNode.Op.(*pb.Op_File).File
208+
require.Equal(t, numActions, len(fileOp.Actions))
209+
require.Equal(t, 1, len(fileOpNode.Inputs))
210+
require.Equal(t, m[fileOpNode.Inputs[0].Digest], arr[0])
211+
require.Equal(t, 0, int(fileOpNode.Inputs[0].Index))
212+
213+
symlinkTests := []*pb.FileActionSymlink{
214+
nil,
215+
{Oldpath: "/src/dir", Newpath: "/srcdir"},
216+
nil,
217+
{Oldpath: "dir/file", Newpath: "/srcdirfile"},
218+
nil,
219+
{Oldpath: "/src/dir/subdir", Newpath: "/src/dir/subdir/nested"},
220+
}
221+
222+
for i := 0; i < numActions; i++ {
223+
expectedOutput := -1
224+
if i == numActions-1 {
225+
expectedOutput = 0
226+
}
227+
228+
require.Equal(t, int(fileOp.Actions[i].Input), i)
229+
require.Equal(t, -1, int(fileOp.Actions[i].SecondaryInput))
230+
require.Equal(t, expectedOutput, int(fileOp.Actions[i].Output))
231+
232+
if symlinkTests[i] == nil {
233+
continue
234+
}
235+
236+
symlink := fileOp.Actions[i].Action.(*pb.FileAction_Symlink).Symlink
237+
238+
require.Equal(t, symlink.Oldpath, symlinkTests[i].Oldpath)
239+
require.Equal(t, symlink.Newpath, symlinkTests[i].Newpath)
240+
}
241+
}
242+
182243
func TestFileRm(t *testing.T) {
183244
t.Parallel()
184245

cmd/buildctl/debug/dumpllb.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ func attr(dgst digest.Digest, op *pb.Op) (string, string) {
130130
name = fmt.Sprintf("mkdir{path=%s}", act.Mkdir.Path)
131131
case *pb.FileAction_Rm:
132132
name = fmt.Sprintf("rm{path=%s}", act.Rm.Path)
133+
case *pb.FileAction_Symlink:
134+
name = fmt.Sprintf("symlink{oldpath=%s, newpath=%s}", act.Symlink.Oldpath, act.Symlink.Newpath)
133135
}
134136

135137
names = append(names, name)

solver/llbsolver/file/backend.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,40 @@ func mkdir(d string, action *pb.FileActionMkDir, user *copy.User, idmap *idtools
6767
return nil
6868
}
6969

70+
func symlink(d string, action *pb.FileActionSymlink, user *copy.User, idmap *idtools.IdentityMapping) (err error) {
71+
defer func() {
72+
var osErr *os.PathError
73+
if errors.As(err, &osErr) {
74+
// remove system root from error path if present
75+
osErr.Path = strings.TrimPrefix(osErr.Path, d)
76+
}
77+
}()
78+
79+
newpath, err := fs.RootPath(d, filepath.Join("/", action.Newpath))
80+
if err != nil {
81+
return errors.WithStack(err)
82+
}
83+
84+
ch, err := mapUserToChowner(user, idmap)
85+
if err != nil {
86+
return err
87+
}
88+
89+
if err := os.Symlink(action.Oldpath, newpath); err != nil {
90+
return errors.WithStack(err)
91+
}
92+
93+
if err := copy.Chown(newpath, nil, ch); err != nil {
94+
return errors.WithStack(err)
95+
}
96+
97+
if err := copy.Utimes(newpath, timestampToTime(action.Timestamp)); err != nil {
98+
return errors.WithStack(err)
99+
}
100+
101+
return nil
102+
}
103+
70104
func mkfile(d string, action *pb.FileActionMkFile, user *copy.User, idmap *idtools.IdentityMapping) (err error) {
71105
defer func() {
72106
var osErr *os.PathError
@@ -304,6 +338,27 @@ func (fb *Backend) Mkfile(ctx context.Context, m, user, group fileoptypes.Mount,
304338
return mkfile(dir, action, u, mnt.m.IdentityMapping())
305339
}
306340

341+
func (fb *Backend) Symlink(ctx context.Context, m, user, group fileoptypes.Mount, action *pb.FileActionSymlink) error {
342+
mnt, ok := m.(*Mount)
343+
if !ok {
344+
return errors.Errorf("invalid mount type %T", m)
345+
}
346+
347+
lm := snapshot.LocalMounter(mnt.m)
348+
dir, err := lm.Mount()
349+
if err != nil {
350+
return err
351+
}
352+
defer lm.Unmount()
353+
354+
u, err := fb.readUserWrapper(action.Owner, user, group)
355+
if err != nil {
356+
return err
357+
}
358+
359+
return symlink(dir, action, u, mnt.m.IdentityMapping())
360+
}
361+
307362
func (fb *Backend) Rm(ctx context.Context, m fileoptypes.Mount, action *pb.FileActionRm) error {
308363
mnt, ok := m.(*Mount)
309364
if !ok {

solver/llbsolver/ops/file.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ func (f *fileOp) CacheMap(ctx context.Context, g session.Group, index int) (*sol
8484
if err != nil {
8585
return nil, false, err
8686
}
87+
case *pb.FileAction_Symlink:
88+
p := a.Symlink.CloneVT()
89+
markInvalid(action.Input)
90+
dt, err = json.Marshal(p)
91+
if err != nil {
92+
return nil, false, err
93+
}
8794
case *pb.FileAction_Rm:
8895
p := a.Rm.CloneVT()
8996
markInvalid(action.Input)
@@ -586,6 +593,14 @@ func (s *FileOpSolver) getInput(ctx context.Context, idx int, inputs []fileoptyp
586593
if err := s.b.Mkdir(ctx, inpMount, user, group, a.Mkdir); err != nil {
587594
return input{}, err
588595
}
596+
case *pb.FileAction_Symlink:
597+
user, group, err := loadOwner(ctx, a.Symlink.Owner)
598+
if err != nil {
599+
return input{}, err
600+
}
601+
if err := s.b.Symlink(ctx, inpMount, user, group, a.Symlink); err != nil {
602+
return input{}, err
603+
}
589604
case *pb.FileAction_Mkfile:
590605
user, group, err := loadOwner(ctx, a.Mkfile.Owner)
591606
if err != nil {

0 commit comments

Comments
 (0)