Skip to content

Commit 439bfca

Browse files
authored
feat: Add support for building systemd system extensions (#734)
* feat: Add support for building systemd system extensions Sysexts are not containers and therefore do not necessarily need to include all their dependencies. Exactly how much to include ultimately depends on how the sysext is intended to be used. To give the spec author full control over this, only the built package and the dependencies explicitly listed in the spec are installed to the sysext. These are extracted rather than installed by the package manager. For now, the generated sysext is a bare erofs filesystem rather than a partitioned disk image. This adds `/sysext` to the azlinux3 and noble targets. These are the only targets with a new enough erofs-utils to include tar support. /etc can only be included in confexts rather than sysexts. For now, move anything in /etc (except systemd) to /usr/share/NAME/etc and copy that data back again at runtime with systemd-tmpfiles. This is what Flatcar does. Any systemd services are automatically started when the sysext is attached thanks to a drop-in against multi-user.target. Signed-off-by: James Le Cuirot <jlecuirot@microsoft.com> * Use a new "sysext" dependency type for sysexts rather than "runtime" This allows existing specs to be extended for sysext use rather than having to create new ones just to avoid unwanted runtime dependencies. Signed-off-by: James Le Cuirot <jlecuirot@microsoft.com> * Run the tests for sysext targets Like the package targets, this builds a container to run the tests in and then throws it away. It is not feasible to test the sysext itself. Signed-off-by: James Le Cuirot <jlecuirot@microsoft.com> * docs: Add system extension documentation Signed-off-by: James Le Cuirot <jlecuirot@microsoft.com> * Temporarily move */sysext targets to */testing/sysext while experimental --------- Signed-off-by: James Le Cuirot <jlecuirot@microsoft.com>
1 parent d6793b8 commit 439bfca

28 files changed

Lines changed: 1000 additions & 44 deletions

deps.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ type PackageDependencies struct {
3131
// Recommends is the list of packages recommended to install with the generated package.
3232
// Note: Not all package managers support this (e.g. rpm)
3333
Recommends map[string]PackageConstraints `yaml:"recommends,omitempty" json:"recommends,omitempty"`
34+
// Sysext is the list of packages to include in the generated system
35+
// extension. No dependency resolution is performed when generating system
36+
// extensions, so all required dependencies must be explicitly listed here.
37+
Sysext map[string]PackageConstraints `yaml:"sysext,omitempty" json:"sysext,omitempty"`
3438

3539
// Test lists any extra packages required for running tests
3640
// These packages are only installed for tests which have steps that require

docs/spec.schema.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,16 @@
12591259
],
12601260
"description": "Runtime is the list of packages required to install/run the package."
12611261
},
1262+
"sysext": {
1263+
"additionalProperties": {
1264+
"$ref": "#/$defs/PackageConstraints"
1265+
},
1266+
"type": [
1267+
"object",
1268+
"null"
1269+
],
1270+
"description": "Sysext is the list of packages to include in the generated system\nextension. No dependency resolution is performed when generating system\nextensions, so all required dependencies must be explicitly listed here."
1271+
},
12621272
"test": {
12631273
"items": {
12641274
"type": [

packaging/linux/deb/template_control.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ func formatVersionConstraint(v string) string {
5757
}
5858
}
5959

60-
// appendConstraints takes an input list of packages and returns a new list of
60+
// AppendConstraints takes an input list of packages and returns a new list of
6161
// packages with the constraints appended for use in a debian/control file.
6262
// The output list is sorted lexicographically.
63-
func appendConstraints(deps map[string]dalec.PackageConstraints) []string {
63+
func AppendConstraints(deps map[string]dalec.PackageConstraints) []string {
6464
if deps == nil {
6565
return nil
6666
}
@@ -144,7 +144,7 @@ func (w *controlWrapper) depends(buf *strings.Builder, depsSpec *dalec.PackageDe
144144
rtDeps[miscDeps] = dalec.PackageConstraints{}
145145
}
146146

147-
deps := appendConstraints(rtDeps)
147+
deps := AppendConstraints(rtDeps)
148148
fmt.Fprintln(buf, multiline("Depends", deps))
149149
}
150150

@@ -159,7 +159,7 @@ func (w *controlWrapper) recommends(buf *strings.Builder, depsSpec *dalec.Packag
159159
return
160160
}
161161

162-
deps := appendConstraints(depsSpec.Recommends)
162+
deps := AppendConstraints(depsSpec.Recommends)
163163
fmt.Fprintln(buf, multiline("Recommends", deps))
164164
}
165165

@@ -170,7 +170,7 @@ func (w *controlWrapper) BuildDeps() fmt.Stringer {
170170

171171
var deps []string
172172
if depsSpec != nil {
173-
deps = appendConstraints(depsSpec.Build)
173+
deps = AppendConstraints(depsSpec.Build)
174174
}
175175

176176
deps = append(deps, fmt.Sprintf("debhelper-compat (= %s)", DebHelperCompat))
@@ -196,7 +196,7 @@ func (w *controlWrapper) Replaces() fmt.Stringer {
196196
return b
197197
}
198198

199-
ls := appendConstraints(replaces)
199+
ls := AppendConstraints(replaces)
200200

201201
fmt.Fprintln(b, multiline("Replaces", ls))
202202
return b
@@ -209,7 +209,7 @@ func (w *controlWrapper) Conflicts() fmt.Stringer {
209209
return b
210210
}
211211

212-
ls := appendConstraints(conflicts)
212+
ls := AppendConstraints(conflicts)
213213
fmt.Fprintln(b, multiline("Conflicts", ls))
214214
return b
215215
}
@@ -221,7 +221,7 @@ func (w *controlWrapper) Provides() fmt.Stringer {
221221
return b
222222
}
223223

224-
ls := appendConstraints(provides)
224+
ls := AppendConstraints(provides)
225225
fmt.Fprintln(b, multiline("Provides", ls))
226226
return b
227227
}

packaging/linux/deb/template_control_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ func TestAppendConstraints(t *testing.T) {
6565

6666
for _, tt := range tests {
6767
t.Run(tt.name, func(t *testing.T) {
68-
if got := appendConstraints(tt.deps); !reflect.DeepEqual(got, tt.want) {
69-
t.Errorf("appendConstraints() = %v, want %v", got, tt.want)
68+
if got := AppendConstraints(tt.deps); !reflect.DeepEqual(got, tt.want) {
69+
t.Errorf("AppendConstraints() = %v, want %v", got, tt.want)
7070
}
7171
})
7272
}

packaging/linux/rpm/template.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ func (w *specWrapper) Recommends() fmt.Stringer {
241241
// NOTE: This is very basic and does not handle things like grouped constraints
242242
// Given this is just trying to shim things to allow either the rpm format or the deb format
243243
// in its basic form, this is sufficient for now.
244-
func formatVersionConstraint(v string) string {
244+
func FormatVersionConstraint(v string) string {
245245
prefix, suffix, ok := strings.Cut(v, " ")
246246
if !ok {
247247
if len(prefix) >= 1 {
@@ -274,7 +274,7 @@ func writeDep(b *strings.Builder, kind, name string, constraints dalec.PackageCo
274274
}
275275

276276
for _, c := range constraints.Version {
277-
fmt.Fprintf(b, "%s: %s %s\n", kind, name, formatVersionConstraint(c))
277+
fmt.Fprintf(b, "%s: %s %s\n", kind, name, FormatVersionConstraint(c))
278278
}
279279
}
280280

targets/linux/build_sysext.sh

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env bash
2+
3+
set -euxo pipefail
4+
5+
NAME=$1
6+
VERSION=$2
7+
ARCH=$3
8+
9+
# Map Docker/Go arch to systemd arch.
10+
case ${ARCH} in
11+
arm|arm64|mips64|ppc64|s390x|sparc64|riscv64) : ;;
12+
mipsle|mips64le|ppc64le) ARCH=${ARCH%le}-le ;;
13+
386) ARCH=x86 ;;
14+
amd64) ARCH=x86-64 ;;
15+
loong64) ARCH=loongarch64 ;;
16+
*)
17+
echo "Unsupported architecture: ${ARCH}" >&2
18+
exit 1 ;;
19+
esac
20+
21+
TMPDIR=$(mktemp -d)
22+
trap 'rm -rf -- "${TMPDIR}"' EXIT
23+
mkdir -p "${TMPDIR}"/usr/lib/extension-release.d
24+
25+
cat > "${TMPDIR}/usr/lib/extension-release.d/extension-release.${NAME}" <<-EOF
26+
ID=_any
27+
ARCHITECTURE=${ARCH}
28+
EXTENSION_RELOAD_MANAGER=1
29+
EOF
30+
31+
cd /input
32+
shopt -s extglob nullglob
33+
34+
# Sysexts cannot include /etc, so move that data to /usr/share/${NAME}/etc and
35+
# copy it to /etc at runtime.
36+
for ITEM in etc/!(systemd); do
37+
mkdir -p "${TMPDIR}"/usr/lib/tmpfiles.d
38+
echo "C+ /${ITEM} - - - - /usr/share/${NAME}/${ITEM}" >> "${TMPDIR}/usr/lib/tmpfiles.d/10-${NAME}.conf"
39+
done
40+
41+
# Automatically start any systemd services when the sysext is attached.
42+
for ITEM in usr/lib/systemd/system/!(*@*).service; do
43+
ITEM=${ITEM##*/}
44+
mkdir -p "${TMPDIR}"/usr/lib/systemd/system/multi-user.target.d
45+
cat > "${TMPDIR}/usr/lib/systemd/system/multi-user.target.d/10-${NAME}-${ITEM%.service}.conf" <<-EOF
46+
[Unit]
47+
Upholds=${ITEM}
48+
EOF
49+
done
50+
51+
tar \
52+
--create \
53+
--owner=root:0 \
54+
--group=root:0 \
55+
--exclude=etc/systemd \
56+
--xattrs-exclude=^btrfs. \
57+
--transform="s:^(bin|sbin|lib|lib64)/:usr/\1/:x" \
58+
--transform="s:^etc\b:usr/share/${NAME//:/\\:}/etc:x" \
59+
?(usr)/ ?(etc)/ ?(opt)/ ?(bin)/ ?(sbin)/ ?(lib)/ ?(lib64)/ \
60+
--directory="${TMPDIR}" \
61+
usr/ \
62+
| \
63+
mkfs.erofs \
64+
--tar=f \
65+
-zlz4hc \
66+
"/output/${NAME}-${VERSION}-${ARCH}.raw"

targets/linux/deb/distro/distro.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ type Config struct {
3939
// that are not inthe base worker config.
4040
// A prime example of this is adding Debian backports on debian distributions.
4141
ExtraRepos []dalec.PackageRepositoryConfig
42+
43+
// erofs-utils 1.7+ is required for tar support.
44+
SysextSupported bool
4245
}
4346

4447
func (cfg *Config) BuildImageConfig(ctx context.Context, sOpt dalec.SourceOpts, spec *dalec.Spec, platform *ocispecs.Platform, targetKey string) (*dalec.DockerImageSpec, error) {
@@ -117,5 +120,12 @@ func (cfg *Config) Handle(ctx context.Context, client gwclient.Client) (*gwclien
117120
Description: "Builds the worker image.",
118121
})
119122

123+
if cfg.SysextSupported {
124+
mux.Add("testing/sysext", linux.HandleSysext(cfg), &targets.Target{
125+
Name: "testing/sysext",
126+
Description: "Builds a systemd system extension image.",
127+
})
128+
}
129+
120130
return mux.Handle(ctx, client)
121131
}

targets/linux/deb/distro/install.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,50 @@ func (d *Config) InstallTestDeps(sOpt dalec.SourceOpts, targetKey string, spec *
197197
).Root()
198198
}
199199
}
200+
201+
func (d *Config) DownloadDeps(worker llb.State, sOpt dalec.SourceOpts, spec *dalec.Spec, targetKey string, constraints map[string]dalec.PackageConstraints, opts ...llb.ConstraintsOpt) llb.State {
202+
if constraints == nil {
203+
return llb.Scratch()
204+
}
205+
206+
opts = append(opts, dalec.ProgressGroup("Downloading dependencies"))
207+
208+
scriptPath := "/tmp/dalec/internal/deb/download.sh"
209+
const scriptSrc = `#!/usr/bin/env bash
210+
set -euxo pipefail
211+
cd /output
212+
213+
# Make sure any cached data from local repos is purged since this should not
214+
# be shared between builds.
215+
rm -f /var/lib/apt/lists/_*
216+
apt autoclean -y
217+
apt update
218+
219+
# Use APT to resolve the constraints and download just the requested packages
220+
# without the sub-dependencies. Ideally, we would resolve all the constraints
221+
# together and match the packages by name, but the resolved name is often
222+
# different. We therefore have to resolve each constraint in turn and assume
223+
# that the last Inst line corresponds to the requested package. This should be
224+
# the case when recommends and suggests are omitted.
225+
for CONSTRAINT; do
226+
apt satisfy -y -s --no-install-recommends --no-install-suggests "${CONSTRAINT}" |
227+
tac |
228+
sed -n -r 's/^Inst ([^ ]+) \(([^ ]+).*/\1=\2/p;T;q' |
229+
xargs -t apt download
230+
done
231+
`
232+
233+
scriptFile := llb.Scratch().File(
234+
llb.Mkfile("download.sh", 0o755, []byte(scriptSrc)),
235+
dalec.WithConstraints(opts...),
236+
)
237+
238+
return worker.Run(
239+
llb.Args(append([]string{scriptPath}, deb.AppendConstraints(constraints)...)),
240+
llb.AddMount(scriptPath, scriptFile, llb.SourcePath("download.sh"), llb.Readonly),
241+
llb.AddMount("/var/lib/dpkg", llb.Scratch(), llb.Tmpfs()),
242+
llb.AddEnv("DEBIAN_FRONTEND", "noninteractive"),
243+
dalec.WithMountedAptCache(d.AptCachePrefix),
244+
dalec.WithConstraints(opts...),
245+
).AddMount("/output", llb.Scratch())
246+
}

targets/linux/deb/distro/pkg.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,20 @@ func (cfg *Config) HandleSourcePkg(ctx context.Context, client gwclient.Client)
245245
return ref, nil, nil
246246
})
247247
}
248+
249+
func (c *Config) ExtractPkg(ctx context.Context, client gwclient.Client, worker llb.State, sOpt dalec.SourceOpts, spec *dalec.Spec, targetKey string, debSt llb.State, opts ...llb.ConstraintsOpt) llb.State {
250+
depDebs := llb.Scratch()
251+
deps := spec.GetPackageDeps(targetKey)
252+
if deps != nil {
253+
depDebs = c.DownloadDeps(worker, sOpt, spec, targetKey, deps.Sysext, opts...)
254+
}
255+
256+
opts = append(opts, dalec.ProgressGroup("Extracting DEBs"))
257+
258+
return worker.Run(
259+
llb.Args([]string{"find", "/input", "-name", "*.deb", "-exec", "dpkg-deb", "--verbose", "--extract", "{}", "/output", ";"}),
260+
llb.AddMount("/input/build", debSt),
261+
llb.AddMount("/input/deps", depDebs),
262+
dalec.WithConstraints(opts...),
263+
).AddMount("/output", llb.Scratch())
264+
}

targets/linux/deb/distro/worker.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,11 @@ func (cfg *Config) Worker(sOpt dalec.SourceOpts, opts ...llb.ConstraintsOpt) (ll
8484
).Root()
8585
return base, nil
8686
}
87+
88+
func (cfg *Config) SysextWorker(worker llb.State, opts ...llb.ConstraintsOpt) llb.State {
89+
return worker.Run(
90+
dalec.WithConstraints(opts...),
91+
AptInstall([]string{"erofs-utils"}, opts...),
92+
dalec.WithMountedAptCache(cfg.AptCachePrefix),
93+
).Root()
94+
}

0 commit comments

Comments
 (0)