diff --git a/docs/spec.schema.json b/docs/spec.schema.json index 3e5cc6871..4f332de26 100644 --- a/docs/spec.schema.json +++ b/docs/spec.schema.json @@ -93,6 +93,13 @@ }, "type": "object", "description": "Licenses is a list of doc files included in the package" + }, + "systemdUnits": { + "additionalProperties": { + "$ref": "#/$defs/SystemdUnitConfig" + }, + "type": "object", + "description": "SystemdUnits is a list of systemd units to include in the package." } }, "additionalProperties": false, @@ -914,6 +921,24 @@ ], "description": "SymlinkTarget specifies the properties of a symlink" }, + "SystemdUnitConfig": { + "properties": { + "name": { + "type": "string", + "description": "Name is the name systemd unit should be copied under.\nNested paths are not supported. It is the user's responsibility\nto name the service with the appropriate extension, i.e. .service, .timer, etc." + }, + "enable": { + "type": "boolean", + "description": "Enable is used to enable the systemd unit on install\nThis determines what will be written to a systemd preset file" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "enable" + ] + }, "Target": { "properties": { "dependencies": { diff --git a/frontend/azlinux/mariner2.go b/frontend/azlinux/mariner2.go index 0bf6bbf92..54a98f9ae 100644 --- a/frontend/azlinux/mariner2.go +++ b/frontend/azlinux/mariner2.go @@ -28,7 +28,7 @@ type mariner2 struct{} func (w mariner2) Base(resolver llb.ImageMetaResolver, opts ...llb.ConstraintsOpt) llb.State { return llb.Image(mariner2Ref, llb.WithMetaResolver(resolver), dalec.WithConstraints(opts...)).Run( - w.Install([]string{"rpm-build", "mariner-rpm-macros", "build-essential", "ca-certificates"}, installWithConstraints(opts)), + w.Install([]string{"rpm-build", "mariner-rpm-macros", "systemd-rpm-macros", "build-essential", "ca-certificates"}, installWithConstraints(opts)), dalec.WithConstraints(opts...), ).Root() } diff --git a/frontend/rpm/template.go b/frontend/rpm/template.go index 2ed696fbb..8b66bef9c 100644 --- a/frontend/rpm/template.go +++ b/frontend/rpm/template.go @@ -40,6 +40,9 @@ BuildArch: noarch {{ .PrepareSources }} {{ .BuildSteps }} {{ .Install }} +{{ .Post }} +{{ .PreUn }} +{{ .PostUn }} {{ .Files }} {{ .Changelog }} `))) @@ -288,6 +291,47 @@ func (w *specWrapper) BuildSteps() fmt.Stringer { return b } +func (w *specWrapper) PreUn() fmt.Stringer { + b := &strings.Builder{} + b.WriteString("%preun\n") + + keys := dalec.SortMapKeys(w.Spec.Artifacts.SystemdUnits) + for _, servicePath := range keys { + serviceName := filepath.Base(servicePath) + fmt.Fprintf(b, "%%systemd_preun %s\n", serviceName) + } + + return b +} + +func (w *specWrapper) Post() fmt.Stringer { + b := &strings.Builder{} + b.WriteString("%post\n") + // TODO: can inject other post install steps here in the future + + keys := dalec.SortMapKeys(w.Spec.Artifacts.SystemdUnits) + for _, servicePath := range keys { + unitConf := w.Spec.Artifacts.SystemdUnits[servicePath].Artifact() + fmt.Fprintf(b, "%%systemd_post %s\n", unitConf.ResolveName(servicePath)) + } + + return b +} + +func (w *specWrapper) PostUn() fmt.Stringer { + b := &strings.Builder{} + b.WriteString("%postun\n") + keys := dalec.SortMapKeys(w.Spec.Artifacts.SystemdUnits) + for _, servicePath := range keys { + cfg := w.Spec.Artifacts.SystemdUnits[servicePath] + a := cfg.Artifact() + serviceName := a.ResolveName(servicePath) + fmt.Fprintf(b, "%%systemd_postun %s\n", serviceName) + } + + return b +} + func (w *specWrapper) Install() fmt.Stringer { b := &strings.Builder{} @@ -356,6 +400,30 @@ func (w *specWrapper) Install() fmt.Stringer { copyArtifact(`%{buildroot}/%{_sysconfdir}`, c, cfg) } + serviceKeys := dalec.SortMapKeys(w.Spec.Artifacts.SystemdUnits) + presetName := "%{name}.preset" + for _, p := range serviceKeys { + cfg := w.Spec.Artifacts.SystemdUnits[p] + // must include systemd unit extension (.service, .socket, .timer, etc.) in name + copyArtifact(`%{buildroot}/%{_unitdir}`, p, cfg.Artifact()) + + verb := "disable" + if cfg.Enable { + verb = "enable" + } + + unitName := filepath.Base(p) + if cfg.Name != "" { + unitName = cfg.Name + } + + fmt.Fprintf(b, "echo '%s %s' >> '%s'\n", verb, unitName, presetName) + } + + if len(serviceKeys) > 0 { + copyArtifact(`%{buildroot}/%{_presetdir}`, presetName, dalec.ArtifactConfig{}) + } + docKeys := dalec.SortMapKeys(w.Spec.Artifacts.Docs) for _, d := range docKeys { cfg := w.Spec.Artifacts.Docs[d] @@ -392,7 +460,7 @@ func (w *specWrapper) Files() fmt.Stringer { binKeys := dalec.SortMapKeys(w.Spec.Artifacts.Binaries) for _, p := range binKeys { cfg := w.Spec.Artifacts.Binaries[p] - full := filepath.Join(`%{_bindir}/`, cfg.SubPath, filepath.Base(p)) + full := filepath.Join(`%{_bindir}/`, cfg.SubPath, cfg.Name) fmt.Fprintln(b, full) } @@ -422,6 +490,17 @@ func (w *specWrapper) Files() fmt.Stringer { fmt.Fprintln(b, fullDirective) } + serviceKeys := dalec.SortMapKeys(w.Spec.Artifacts.SystemdUnits) + for _, p := range serviceKeys { + serviceName := filepath.Base(p) + unitPath := filepath.Join(`%{_unitdir}/`, serviceName) + fmt.Fprintln(b, unitPath) + } + + if len(serviceKeys) > 0 { + fmt.Fprintln(b, "%{_presetdir}/%{name}.preset") + } + docKeys := dalec.SortMapKeys(w.Spec.Artifacts.Docs) for _, d := range docKeys { cfg := w.Spec.Artifacts.Docs[d] diff --git a/frontend/rpm/template_test.go b/frontend/rpm/template_test.go index 7b34f389d..01263264d 100644 --- a/frontend/rpm/template_test.go +++ b/frontend/rpm/template_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/Azure/dalec" + "gotest.tools/v3/assert" ) func TestTemplateSources(t *testing.T) { @@ -206,3 +207,20 @@ func TestTemplateSources(t *testing.T) { } }) } + +func TestTemplate_Artifacts(t *testing.T) { + + w := &specWrapper{Spec: &dalec.Spec{ + Artifacts: dalec.Artifacts{ + SystemdUnits: map[string]dalec.SystemdUnitConfig{ + "test.service": {}, + }, + }, + }} + + got := w.PostUn().String() + want := `%postun +%systemd_postun test.service +` + assert.Equal(t, want, got) +} diff --git a/go.mod b/go.mod index 95561b745..d5796b73b 100644 --- a/go.mod +++ b/go.mod @@ -19,12 +19,13 @@ require ( github.com/opencontainers/image-spec v1.1.0 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 - github.com/tonistiigi/fsutil v0.0.0-20240424095704-91a3fc46842c github.com/stretchr/testify v1.8.4 + github.com/tonistiigi/fsutil v0.0.0-20240424095704-91a3fc46842c go.opentelemetry.io/otel v1.21.0 golang.org/x/exp v0.0.0-20240318143956-a85f2c67cd81 golang.org/x/sys v0.18.0 google.golang.org/grpc v1.59.0 + gotest.tools/v3 v3.5.0 ) require ( @@ -104,5 +105,4 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gotest.tools/v3 v3.5.0 // indirect ) diff --git a/spec.go b/spec.go index b91a2145f..92c7e9335 100644 --- a/spec.go +++ b/spec.go @@ -4,6 +4,7 @@ package dalec import ( "fmt" "io/fs" + "path/filepath" "regexp" "strings" "time" @@ -135,9 +136,29 @@ type Artifacts struct { Docs map[string]ArtifactConfig `yaml:"docs,omitempty" json:"docs,omitempty"` // Licenses is a list of doc files included in the package Licenses map[string]ArtifactConfig `yaml:"licenses,omitempty" json:"licenses,omitempty"` + // SystemdUnits is a list of systemd units to include in the package. + SystemdUnits map[string]SystemdUnitConfig `yaml:"systemdUnits,omitempty" json:"systemdUnits,omitempty"` // TODO: other types of artifacts (systtemd units, libexec, etc) } +type SystemdUnitConfig struct { + // Name is the name systemd unit should be copied under. + // Nested paths are not supported. It is the user's responsibility + // to name the service with the appropriate extension, i.e. .service, .timer, etc. + Name string `yaml:"name,omitempty" json:"name"` + + // Enable is used to enable the systemd unit on install + // This determines what will be written to a systemd preset file + Enable bool `yaml:"enable,omitempty" json:"enable"` +} + +func (s SystemdUnitConfig) Artifact() ArtifactConfig { + return ArtifactConfig{ + SubPath: "", + Name: s.Name, + } +} + // CreateArtifactDirectories describes various directories that should be created on install. // CreateArtifactDirectories represents different directory paths that are common to RPM systems. type CreateArtifactDirectories struct { @@ -167,6 +188,30 @@ type ArtifactConfig struct { Name string `yaml:"name,omitempty" json:"name,omitempty"` } +func (a *ArtifactConfig) ResolveName(path string) string { + if a.Name != "" { + return a.Name + } + return filepath.Base(path) +} + +// ServiceConfig is the configuration for a service to include in the package. +type ServiceConfig struct { + Name string `yaml:"name" json:"name" jsonschema:"omitempty"` + + // Some services don't support restarting, in which case this should be set to true + NoRestart bool `yaml:"noRestart,omitempty" json:"noRestart,omitempty"` + + Disable bool `yaml:"disable,omitempty" json:"disable,omitempty"` +} + +func (s ServiceConfig) Artifact() ArtifactConfig { + return ArtifactConfig{ + SubPath: "", + Name: s.Name, + } +} + // IsEmpty is used to determine if there are any artifacts to include in the package. func (a *Artifacts) IsEmpty() bool { if len(a.Binaries) > 0 { @@ -181,6 +226,11 @@ func (a *Artifacts) IsEmpty() bool { if len(a.ConfigFiles) > 0 { return false } + + if len(a.SystemdUnits) > 0 { + return false + } + if len(a.Docs) > 0 { return false } diff --git a/test/azlinux_test.go b/test/azlinux_test.go index b32d6151e..d1e4b7e06 100644 --- a/test/azlinux_test.go +++ b/test/azlinux_test.go @@ -340,6 +340,181 @@ echo "$BAR" > bar.txt runTest(t, distroSigningTest(t, &spec, signTarget)) }) + + t.Run("test systemd unit", func(t *testing.T) { + t.Parallel() + spec := &dalec.Spec{ + Name: "test-systemd-unit", + Description: "Test systemd unit", + Website: "https://www.github.com/Azure/dalec", + Version: "0.0.1", + Revision: "1", + Vendor: "Microsoft", + License: "Apache 2.0", + Packager: "Microsoft ", + Sources: map[string]dalec.Source{ + "src": { + Inline: &dalec.SourceInline{ + Dir: &dalec.SourceInlineDir{ + + Files: map[string]*dalec.SourceInlineFile{ + "simple.service": { + Contents: `, +[Unit] +Description=Phony Service +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/service +Restart=always + +[Install] +WantedBy=multi-user.target +`}, + }, + }, + }, + }, + }, + Artifacts: dalec.Artifacts{ + SystemdUnits: map[string]dalec.SystemdUnitConfig{ + "src/simple.service": { + Enable: true, + }, + }, + }, + Tests: []*dalec.TestSpec{ + { + Name: "Check service files", + Files: map[string]dalec.FileCheckOutput{ + "/usr/lib/systemd/system/simple.service": { + CheckOutput: dalec.CheckOutput{Contains: []string{"ExecStart=/usr/bin/service"}}, + Permissions: 0644, + }, + "/usr/lib/systemd/system-preset/test-systemd-unit.preset": { + CheckOutput: dalec.CheckOutput{Contains: []string{"enable simple.service"}}, + Permissions: 0644, + }, + }, + }, + }, + } + + testEnv.RunTest(ctx, t, func(ctx context.Context, client gwclient.Client) (*gwclient.Result, error) { + req := newSolveRequest(withBuildTarget(buildTarget), withSpec(ctx, t, spec)) + return client.Solve(ctx, req) + }) + + // Test to ensure disabling works by default + spec.Artifacts.SystemdUnits["src/simple.service"] = dalec.SystemdUnitConfig{} + spec.Tests = []*dalec.TestSpec{ + { + Name: "Check service files", + Files: map[string]dalec.FileCheckOutput{ + "/usr/lib/systemd/system/simple.service": { + CheckOutput: dalec.CheckOutput{Contains: []string{"ExecStart=/usr/bin/service"}}, + Permissions: 0644, + }, + "/usr/lib/systemd/system-preset/test-systemd-unit.preset": { + // This is the only change from the previous test, service should be + // disabled in preset + CheckOutput: dalec.CheckOutput{Contains: []string{"disable simple.service"}}, + Permissions: 0644, + }, + }, + }, + } + + testEnv.RunTest(ctx, t, func(ctx context.Context, client gwclient.Client) (*gwclient.Result, error) { + req := newSolveRequest(withBuildTarget(buildTarget), withSpec(ctx, t, spec)) + return client.Solve(ctx, req) + }) + }) + + t.Run("test systemd unit multiple components", func(t *testing.T) { + t.Parallel() + spec := &dalec.Spec{ + Name: "test-systemd-unit", + Description: "Test systemd unit", + Website: "https://www.github.com/Azure/dalec", + Version: "0.0.1", + Revision: "1", + Vendor: "Microsoft", + License: "Apache 2.0", + Packager: "Microsoft ", + Sources: map[string]dalec.Source{ + "src": { + Inline: &dalec.SourceInline{ + Dir: &dalec.SourceInlineDir{ + + Files: map[string]*dalec.SourceInlineFile{ + "foo.service": { + Contents: ` +# simple-socket.service +[Unit] +Description=Foo Service +After=network.target foo.socket +Requires=foo.socket + +[Service] +Type=simple +ExecStart=/usr/bin/foo +ExecReload=/bin/kill -HUP $MAINPID +StandardOutput=journal +StandardError=journal +`}, + + "foo.socket": { + Contents: ` +[Unit] +Description=foo socket +PartOf=foo.service + +[Socket] +ListenStream=127.0.0.1:8080 + +[Install] +WantedBy=sockets.target + `, + }, + }, + }, + }, + }, + }, + Artifacts: dalec.Artifacts{ + SystemdUnits: map[string]dalec.SystemdUnitConfig{ + "src/foo.service": {}, + "src/foo.socket": { + Enable: true, + }, + }, + }, + Tests: []*dalec.TestSpec{ + { + Name: "Check service files", + Files: map[string]dalec.FileCheckOutput{ + "/usr/lib/systemd/system/foo.service": { + CheckOutput: dalec.CheckOutput{Contains: []string{"ExecStart=/usr/bin/foo"}}, + Permissions: 0644, + }, + "/usr/lib/systemd/system-preset/test-systemd-unit.preset": { + CheckOutput: dalec.CheckOutput{Contains: []string{"enable foo.socket", + "disable foo.service"}}, + Permissions: 0644, + }, + }, + }, + }, + } + + testEnv.RunTest(ctx, t, func(ctx context.Context, client gwclient.Client) (*gwclient.Result, error) { + req := newSolveRequest(withBuildTarget(buildTarget), withSpec(ctx, t, spec)) + return client.Solve(ctx, req) + }) + }) + t.Run("go module", func(t *testing.T) { t.Parallel() ctx := startTestSpan(baseCtx, t)