Skip to content

Commit a8a9b97

Browse files
committed
fix(rootless): make daemonless build pipeline actually work
The rootless runtime could not complete a real prepare+build. Four independent defects, fixed here: - run.go: child args were NUL-separated and passed via os.Setenv, which rejects NUL bytes ("setenv: invalid argument") for any 2+ arg command. Switch the separator to ASCII Unit Separator (0x1f). - runtime.go: the rootless backend has no OCI ENTRYPOINT, so dispatched bare sub-commands (["prepare", ...]) were exec'd directly and ENOENT'd. Prepend the /usr/bin/yap entrypoint in Runtime.Run. - run.go: control env vars (_YAP_ROOTLESSKIT_*) were left set across the final syscall.Exec, so the real yap re-entered MaybeRunAsChild and ran execInRootfs a second time inside the pivoted rootfs — where the host workdir no longer exists — producing a spurious workspace-bind ENOENT. Unset them before exec. - run.go: the builder rootfs ships an empty /etc/resolv.conf, so glibc fell back to ::1:53 and every apt/dnf lookup failed. Bind-mount the host resolv.conf (best-effort) since rootlesskit shares the host netns. Also forward --repo / --allow-unverified-repos / --target-arch from the CLI build invocation into the dispatched prepare+build steps (container_run.go, build.go) so vendor repositories resolve inside the builder instead of failing with "unresolvable packages".
1 parent 384da4e commit a8a9b97

4 files changed

Lines changed: 132 additions & 12 deletions

File tree

cmd/yap/command/build.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,19 @@ var buildCmd = &cobra.Command{
122122
// when the user explicitly requested -s (skip-sync) or -d (no-makedeps),
123123
// which implies they have already prepared the environment.
124124
skipPrepare := buildOpts.SkipSyncDeps || buildOpts.NoMakeDeps
125-
buildArgs := []string{buildCommand, distroTag, "/project"}
126125

127-
if RunPipelineInContainer(distroTag, fullJSONPath, buildArgs, skipPrepare) {
126+
// Forward the flags that affect dependency resolution into the
127+
// container. Without this, extra repos (--repo), the unverified
128+
// trust opt-in (-U) and the cross arch (--target-arch) are lost on
129+
// dispatch, so vendor packages fail to resolve inside the builder.
130+
buildArgs := append([]string{buildCommand, distroTag, "/project"},
131+
forwardedBuildFlags()...)
132+
133+
// prepare also needs --repo/--target-arch so the makedeps step can
134+
// see vendor repositories and the correct toolchain.
135+
prepareArgs := forwardedPrepareFlags()
136+
137+
if RunPipelineInContainer(distroTag, fullJSONPath, buildArgs, prepareArgs, skipPrepare) {
128138
return nil
129139
}
130140
}
@@ -199,6 +209,44 @@ var buildCmd = &cobra.Command{
199209
},
200210
}
201211

212+
// forwardedBuildFlags returns the subset of build flags that must be replayed
213+
// inside the dispatched container so dependency resolution matches the host
214+
// invocation: extra repos, the unverified-trust opt-in, and the cross arch.
215+
func forwardedBuildFlags() []string {
216+
var out []string
217+
218+
for _, r := range buildOpts.ExtraRepos {
219+
out = append(out, "--repo", r)
220+
}
221+
222+
if buildOpts.AllowUnverifiedRepos {
223+
out = append(out, "--allow-unverified-repos")
224+
}
225+
226+
if buildOpts.TargetArch != "" {
227+
out = append(out, "--target-arch", buildOpts.TargetArch)
228+
}
229+
230+
return out
231+
}
232+
233+
// forwardedPrepareFlags returns the flags the chained `yap prepare` step needs
234+
// so makedeps resolution sees the same vendor repositories and toolchain as
235+
// the build step.
236+
func forwardedPrepareFlags() []string {
237+
var out []string
238+
239+
for _, r := range buildOpts.ExtraRepos {
240+
out = append(out, "--repo", r)
241+
}
242+
243+
if buildOpts.TargetArch != "" {
244+
out = append(out, "--target-arch", buildOpts.TargetArch)
245+
}
246+
247+
return out
248+
}
249+
202250
// validateCompression validates that the compression algorithm is supported,
203251
// using the canonical set in pkg/constants shared with the MCP server.
204252
func validateCompression(compression string) error {

cmd/yap/command/container_run.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,16 @@ func RunCommandInContainer(distro, workDir string, subArgs []string) bool {
106106
//
107107
// - distro: distribution tag, e.g. "ubuntu-noble"
108108
// - workDir: host directory to mount as /project
109-
// - buildArgs: arguments for the inner yap build command (distroTag + path)
109+
// - buildArgs: arguments for the inner yap build command (distroTag + path
110+
// plus any forwarded build flags such as --repo, -U, --target-arch)
111+
// - prepareArgs: extra flags to forward to the chained `yap prepare` step
112+
// (e.g. --repo, --target-arch) so the prepare environment matches the
113+
// build — repos added on the build side are not visible to prepare unless
114+
// forwarded here
110115
// - skipPrepare: if true, skip the prepare step (user passed -s or -d)
111116
//
112117
// Returns true if dispatched, false if caller should proceed natively.
113-
func RunPipelineInContainer(distro, workDir string, buildArgs []string, skipPrepare bool) bool {
118+
func RunPipelineInContainer(distro, workDir string, buildArgs, prepareArgs []string, skipPrepare bool) bool {
114119
if IsInsideContainer() {
115120
return false
116121
}
@@ -142,7 +147,12 @@ func RunPipelineInContainer(distro, workDir string, buildArgs []string, skipPrep
142147
if skipPrepare {
143148
shellCmd = buildCmd
144149
} else {
145-
shellCmd = "yap prepare " + distro + " && " + buildCmd
150+
prepareCmd := "yap prepare " + distro
151+
if len(prepareArgs) > 0 {
152+
prepareCmd += " " + shellJoinArgs(prepareArgs)
153+
}
154+
155+
shellCmd = prepareCmd + " && " + buildCmd
146156
}
147157

148158
if err := rt.RunShell(distro, workDir, shellCmd); err != nil {

pkg/container/rootless/run.go

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const (
3030
envChildRootfs = "_YAP_ROOTLESSKIT_ROOTFS"
3131
// envChildWorkDir is the workspace path passed to the child.
3232
envChildWorkDir = "_YAP_ROOTLESSKIT_WORKDIR"
33-
// envChildArgs is the serialised command args passed to the child (NUL-separated).
33+
// envChildArgs is the serialised command args passed to the child (argSep-separated).
3434
envChildArgs = "_YAP_ROOTLESSKIT_ARGS"
3535
)
3636

@@ -184,14 +184,17 @@ func execInRootfs(rootfs, workDir string, args []string) error {
184184

185185
// Bind-mount workspace.
186186
if workDir != "" && workDir != "." {
187-
wsTarget := filepath.Join(rootfs, "workspace")
187+
wsTarget := filepath.Join(rootfs, "project")
188188

189189
if err := bindMount(workDir, wsTarget); err != nil {
190190
return errors.Wrap(err, errors.ErrTypeFileSystem, "failed to bind mount workspace").
191191
WithOperation("execInRootfs")
192192
}
193193
}
194194

195+
// Provide working DNS inside the rootfs (best-effort).
196+
setupResolvConf(rootfs)
197+
195198
if err := pivotOrChroot(rootfs); err != nil {
196199
return errors.Wrap(err, errors.ErrTypeFileSystem, "failed to pivot root or chroot").
197200
WithOperation("execInRootfs")
@@ -207,6 +210,17 @@ func execInRootfs(rootfs, workDir string, args []string) error {
207210
bin = args[0]
208211
}
209212

213+
// Clear the rootless control env vars before handing off to the real
214+
// target. Otherwise the exec'd binary re-enters MaybeRunAsChild (called
215+
// early in main, before cobra), sees envExecMode still set, and re-runs
216+
// execInRootfs a SECOND time — now inside the already-pivoted rootfs,
217+
// where the host-absolute workDir no longer exists, producing a spurious
218+
// ENOENT on the workspace bind. YAP_IN_CONTAINER is intentionally kept so
219+
// the inner process does not re-dispatch into a container.
220+
for _, k := range []string{envExecMode, envChildRootfs, envChildWorkDir, envChildArgs} {
221+
_ = os.Unsetenv(k)
222+
}
223+
210224
return syscall.Exec(bin, args, os.Environ()) //nolint:gosec
211225
}
212226

@@ -240,6 +254,39 @@ func pivotOrChroot(newRoot string) error {
240254
return os.Chdir("/")
241255
}
242256

257+
// setupResolvConf bind-mounts the host's /etc/resolv.conf into the rootfs so
258+
// name resolution works inside the pivoted environment. rootlesskit shares the
259+
// host network namespace, so the host resolver is reachable — but the builder
260+
// rootfs ships an empty /etc/resolv.conf, which makes glibc fall back to
261+
// localhost (::1:53) and every lookup fails. Best-effort: all failures are
262+
// logged and ignored so a missing resolv.conf never aborts the build.
263+
func setupResolvConf(rootfs string) {
264+
hostResolv, err := filepath.EvalSymlinks("/etc/resolv.conf")
265+
if err != nil {
266+
return
267+
}
268+
269+
if _, err := os.Stat(hostResolv); err != nil {
270+
return
271+
}
272+
273+
dst := filepath.Join(rootfs, "etc", "resolv.conf")
274+
275+
// Ensure a regular file exists at dst to bind onto.
276+
if _, err := os.Stat(dst); os.IsNotExist(err) { //nolint:gosec // path from rootfsPath, not user input
277+
_ = os.MkdirAll(filepath.Dir(dst), 0o755) //nolint:gosec // path from rootfsPath, not user input
278+
279+
f, cerr := os.OpenFile(dst, os.O_CREATE, 0o644) //nolint:gosec // path from rootfsPath, not user input
280+
if cerr == nil {
281+
_ = f.Close()
282+
}
283+
}
284+
285+
if err := syscall.Mount(hostResolv, dst, "", syscall.MS_BIND, ""); err != nil {
286+
logger.Warn("failed to bind mount resolv.conf", "error", err)
287+
}
288+
}
289+
243290
// bindMount bind-mounts src to dest.
244291
func bindMount(src, dest string) error {
245292
if err := os.MkdirAll(dest, 0o755); err != nil { //nolint:gosec // path from rootfsPath, not user input
@@ -251,13 +298,20 @@ func bindMount(src, dest string) error {
251298
return syscall.Mount(src, dest, "", syscall.MS_BIND|syscall.MS_REC, "")
252299
}
253300

254-
// joinNUL encodes a string slice as NUL-separated bytes.
301+
// argSep separates encoded child args. We use ASCII Unit Separator (0x1f)
302+
// rather than NUL: the encoded value is passed through os.Setenv, which
303+
// rejects any string containing a NUL byte ("setenv: invalid argument"), so a
304+
// NUL separator broke every command with 2+ args. 0x1f never appears in real
305+
// argv (paths, flags) yet is still a safe in-band delimiter.
306+
const argSep = '\x1f'
307+
308+
// joinNUL encodes a string slice as argSep-separated bytes.
255309
func joinNUL(ss []string) string {
256310
var b strings.Builder
257311

258312
for i, s := range ss {
259313
if i > 0 {
260-
b.WriteByte(0)
314+
b.WriteByte(argSep)
261315
}
262316

263317
b.WriteString(s)
@@ -266,14 +320,14 @@ func joinNUL(ss []string) string {
266320
return b.String()
267321
}
268322

269-
// splitNUL decodes a NUL-separated string into a slice.
323+
// splitNUL decodes an argSep-separated string into a slice.
270324
func splitNUL(s string) []string {
271325
var result []string
272326

273327
cur := &strings.Builder{}
274328

275329
for _, c := range s {
276-
if c == 0 {
330+
if c == argSep {
277331
result = append(result, cur.String())
278332
cur.Reset()
279333
} else {

pkg/container/rootless/runtime.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,17 @@ func (r *Runtime) Pull(distro string) error {
2727
return PullImage(distro)
2828
}
2929

30+
// entrypoint is the builder image ENTRYPOINT. The CLI runtime relies on the
31+
// OCI ENTRYPOINT (["yap"]) and therefore dispatches bare sub-commands like
32+
// ["prepare", ...]. The rootless backend has no ENTRYPOINT and execs args[0]
33+
// directly, so we must prepend the yap binary path ourselves.
34+
const entrypoint = "/usr/bin/yap"
35+
3036
// Run executes args inside the distro rootfs with workDir bind-mounted as /workspace.
37+
// args are the bare sub-command + flags (no binary name), matching the CLI
38+
// runtime contract; the image ENTRYPOINT (yap) is prepended here.
3139
func (r *Runtime) Run(distro, workDir string, args []string) error {
32-
return RunInRootless(distro, workDir, args)
40+
return RunInRootless(distro, workDir, append([]string{entrypoint}, args...))
3341
}
3442

3543
// RunShell executes a shell command string inside the distro rootfs.

0 commit comments

Comments
 (0)