diff --git a/docs/spec.schema.json b/docs/spec.schema.json index 93ea97b9..d7c0979d 100644 --- a/docs/spec.schema.json +++ b/docs/spec.schema.json @@ -358,6 +358,17 @@ "type": "object", "description": "ImageConfig is the configuration for the output image." }, + "PackageConfig": { + "properties": { + "signer": { + "$ref": "#/$defs/Frontend", + "description": "Signer is the configuration to use for signing packages" + } + }, + "additionalProperties": false, + "type": "object", + "description": "PackageConfig encapsulates the configuration for artifact targets" + }, "PackageDependencies": { "properties": { "build": { @@ -791,6 +802,10 @@ "$ref": "#/$defs/PackageDependencies", "description": "Dependencies are the different dependencies that need to be specified in the package.\nDependencies are overwritten if specified in the target map for the requested distro." }, + "package_config": { + "$ref": "#/$defs/PackageConfig", + "description": "PackageConfig is the configuration to use for artifact targets, such as\nrpms, debs, or zip files containing Windows binaries" + }, "image": { "$ref": "#/$defs/ImageConfig", "description": "Image is the image configuration when the target output is a container image.\nThis is overwritten if specified in the target map for the requested distro." @@ -858,6 +873,10 @@ }, "type": "array", "description": "Tests are the list of tests to run which are specific to the target.\nTests are appended to the list of tests in the main [Spec]" + }, + "package_config": { + "$ref": "#/$defs/PackageConfig", + "description": "PackageConfig is the configuration to use for artifact targets, such as\nrpms, debs, or zip files containing Windows binaries" } }, "additionalProperties": false, diff --git a/frontend/mariner2/handle_rpm.go b/frontend/mariner2/handle_rpm.go index 41d5ec03..c7367f54 100644 --- a/frontend/mariner2/handle_rpm.go +++ b/frontend/mariner2/handle_rpm.go @@ -48,6 +48,15 @@ func handleRPM(ctx context.Context, client gwclient.Client) (*gwclient.Result, e return nil, nil, err } + if signer, ok := spec.GetSigner(targetKey); ok { + signed, err := frontend.ForwardToSigner(ctx, client, platform, signer, st) + if err != nil { + return nil, nil, err + } + + st = signed + } + def, err := st.Marshal(ctx, pg) if err != nil { return nil, nil, fmt.Errorf("error marshalling llb: %w", err) @@ -64,6 +73,7 @@ func handleRPM(ctx context.Context, client gwclient.Client) (*gwclient.Result, e if err != nil { return nil, nil, err } + return ref, nil, nil }) } diff --git a/frontend/request.go b/frontend/request.go index b4ecff9c..570b7b13 100644 --- a/frontend/request.go +++ b/frontend/request.go @@ -2,6 +2,7 @@ package frontend import ( "context" + "fmt" "github.com/Azure/dalec" "github.com/goccy/go-yaml" @@ -10,6 +11,7 @@ import ( "github.com/moby/buildkit/frontend/dockerui" gwclient "github.com/moby/buildkit/frontend/gateway/client" "github.com/moby/buildkit/solver/pb" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" ) @@ -116,3 +118,66 @@ func marshalDockerfile(ctx context.Context, dt []byte, opts ...llb.ConstraintsOp st := llb.Scratch().File(llb.Mkfile(dockerui.DefaultDockerfileName, 0600, dt), opts...) return st.Marshal(ctx) } + +func ForwardToSigner(ctx context.Context, client gwclient.Client, platform *ocispecs.Platform, cfg *dalec.Frontend, s llb.State) (llb.State, error) { + const ( + sourceKey = "source" + contextKey = "context" + inputKey = "input" + ) + + opts := client.BuildOpts().Opts + + req, err := newSolveRequest(toFrontend(cfg)) + if err != nil { + return llb.Scratch(), err + } + + for k, v := range opts { + if k == "source" || k == "cmdline" { + continue + } + req.FrontendOpt[k] = v + } + + inputs, err := client.Inputs(ctx) + if err != nil { + return llb.Scratch(), err + } + + m := make(map[string]*pb.Definition) + + for k, st := range inputs { + def, err := st.Marshal(ctx) + if err != nil { + return llb.Scratch(), err + } + m[k] = def.ToPB() + } + req.FrontendInputs = m + + stateDef, err := s.Marshal(ctx) + if err != nil { + return llb.Scratch(), err + } + + req.FrontendOpt[contextKey] = compound(inputKey, contextKey) + req.FrontendInputs[contextKey] = stateDef.ToPB() + req.FrontendOpt["dalec.target"] = opts["dalec.target"] + + res, err := client.Solve(ctx, req) + if err != nil { + return llb.Scratch(), err + } + + ref, err := res.SingleRef() + if err != nil { + return llb.Scratch(), err + } + + return ref.ToState() +} + +func compound(k, v string) string { + return fmt.Sprintf("%s:%s", k, v) +} diff --git a/frontend/windows/handle_container.go b/frontend/windows/handle_container.go index 08bf58ea..889d3e57 100644 --- a/frontend/windows/handle_container.go +++ b/frontend/windows/handle_container.go @@ -54,6 +54,15 @@ func handleContainer(ctx context.Context, client gwclient.Client) (*gwclient.Res return nil, nil, fmt.Errorf("unable to build binary %w", err) } + if signer, ok := spec.GetSigner(targetKey); ok { + signed, err := frontend.ForwardToSigner(ctx, client, platform, signer, bin) + if err != nil { + return nil, nil, err + } + + bin = signed + } + baseImgName := getBaseOutputImage(spec, targetKey, defaultBaseImage) baseImage := llb.Image(baseImgName, llb.Platform(targetPlatform)) diff --git a/frontend/windows/handle_zip.go b/frontend/windows/handle_zip.go index a56f5ae1..8e690c22 100644 --- a/frontend/windows/handle_zip.go +++ b/frontend/windows/handle_zip.go @@ -43,6 +43,15 @@ func handleZip(ctx context.Context, client gwclient.Client) (*gwclient.Result, e return nil, nil, fmt.Errorf("unable to build binaries: %w", err) } + if signer, ok := spec.GetSigner(targetKey); ok { + signed, err := frontend.ForwardToSigner(ctx, client, platform, signer, bin) + if err != nil { + return nil, nil, err + } + + bin = signed + } + st := getZipLLB(worker, spec.Name, bin) if err != nil { return nil, nil, err diff --git a/helpers.go b/helpers.go index 189a2c85..dde62583 100644 --- a/helpers.go +++ b/helpers.go @@ -312,3 +312,21 @@ func (s *Spec) GetSymlinks(target string) map[string]SymlinkTarget { return lm } + +func (s *Spec) GetSigner(targetKey string) (*Frontend, bool) { + if s.Targets != nil { + if t, ok := s.Targets[targetKey]; ok && hasValidSigner(t.PackageConfig) { + return t.PackageConfig.Signer, true + } + } + + if hasValidSigner(s.PackageConfig) { + return s.PackageConfig.Signer, true + } + + return nil, false +} + +func hasValidSigner(pc *PackageConfig) bool { + return pc != nil && pc.Signer != nil && pc.Signer.Image != "" +} diff --git a/spec.go b/spec.go index dffb57e3..f9d75092 100644 --- a/spec.go +++ b/spec.go @@ -82,6 +82,9 @@ type Spec struct { // Dependencies are the different dependencies that need to be specified in the package. // Dependencies are overwritten if specified in the target map for the requested distro. Dependencies *PackageDependencies `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` + // PackageConfig is the configuration to use for artifact targets, such as + // rpms, debs, or zip files containing Windows binaries + PackageConfig *PackageConfig `yaml:"package_config,omitempty" json:"package_config,omitempty"` // Image is the image configuration when the target output is a container image. // This is overwritten if specified in the target map for the requested distro. Image *ImageConfig `yaml:"image,omitempty" json:"image,omitempty"` @@ -402,6 +405,16 @@ type Target struct { // Tests are the list of tests to run which are specific to the target. // Tests are appended to the list of tests in the main [Spec] Tests []*TestSpec `yaml:"tests,omitempty" json:"tests,omitempty"` + + // PackageConfig is the configuration to use for artifact targets, such as + // rpms, debs, or zip files containing Windows binaries + PackageConfig *PackageConfig `yaml:"package_config,omitempty" json:"package_config,omitempty"` +} + +// PackageConfig encapsulates the configuration for artifact targets +type PackageConfig struct { + // Signer is the configuration to use for signing packages + Signer *Frontend `yaml:"signer,omitempty" json:"signer,omitempty"` } // TestSpec is used to execute tests against a container with the package installed in it. diff --git a/test/fixtures/signer.go b/test/fixtures/signer.go new file mode 100644 index 00000000..677440f3 --- /dev/null +++ b/test/fixtures/signer.go @@ -0,0 +1,66 @@ +package fixtures + +import ( + "context" + "encoding/json" + + "github.com/Azure/dalec" + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/exporter/containerimage/exptypes" + "github.com/moby/buildkit/frontend/dockerui" + gwclient "github.com/moby/buildkit/frontend/gateway/client" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" +) + +func PhonySigner(ctx context.Context, gwc gwclient.Client) (*gwclient.Result, error) { + dc, err := dockerui.NewClient(gwc) + if err != nil { + return nil, err + } + + bctx, err := dc.MainContext(ctx) + if err != nil { + return nil, err + } + + st := llb.Image("golang:1.21", llb.WithMetaResolver(gwc)). + Run( + llb.Args([]string{"go", "build", "-o=/build/out", "./test/fixtures/signer"}), + llb.AddEnv("CGO_ENABLED", "0"), + goModCache, + goBuildCache, + llb.Dir("/build/src"), + llb.AddMount("/build/src", *bctx, llb.Readonly), + ). + AddMount("/build/out", llb.Scratch()) + + cfg := dalec.DockerImageSpec{ + Config: dalec.DockerImageConfig{ + ImageConfig: ocispecs.ImageConfig{ + Entrypoint: []string{"/signer"}, + }, + }, + } + injectFrontendLabels(&cfg) + + dt, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + res, err := gwc.Solve(ctx, gwclient.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + res.AddMeta(exptypes.ExporterImageConfigKey, dt) + + return res, nil +} diff --git a/test/fixtures/signer/main.go b/test/fixtures/signer/main.go new file mode 100644 index 00000000..feccc320 --- /dev/null +++ b/test/fixtures/signer/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/Azure/dalec/frontend" + "github.com/moby/buildkit/client/llb" + gwclient "github.com/moby/buildkit/frontend/gateway/client" + "github.com/moby/buildkit/frontend/gateway/grpcclient" + "github.com/moby/buildkit/util/appcontext" + "github.com/moby/buildkit/util/bklog" + "github.com/sirupsen/logrus" + "google.golang.org/grpc/grpclog" +) + +func main() { + bklog.L.Logger.SetOutput(os.Stderr) + grpclog.SetLoggerV2(grpclog.NewLoggerV2WithVerbosity(bklog.L.WriterLevel(logrus.InfoLevel), bklog.L.WriterLevel(logrus.WarnLevel), bklog.L.WriterLevel(logrus.ErrorLevel), 1)) + + ctx := appcontext.Context() + + if err := grpcclient.RunFromEnvironment(ctx, func(ctx context.Context, c gwclient.Client) (*gwclient.Result, error) { + bopts := c.BuildOpts().Opts + target := bopts["dalec.target"] + + inputs, err := c.Inputs(ctx) + if err != nil { + return nil, err + } + + type config struct { + OS string + } + + cfg := config{} + + switch target { + case "windowscross", "windows": + cfg.OS = "windows" + default: + cfg.OS = "linux" + } + + curFrontend, ok := c.(frontend.CurrentFrontend) + if !ok { + return nil, fmt.Errorf("cast to currentFrontend failed") + } + + basePtr, err := curFrontend.CurrentFrontend() + if err != nil || basePtr == nil { + if err == nil { + err = fmt.Errorf("base frontend ptr was nil") + } + return nil, err + } + + inputId := strings.TrimPrefix(bopts["context"], "input:") + _, ok = inputs[inputId] + if !ok { + return nil, fmt.Errorf("no artifact state provided to signer") + } + + configBytes, err := json.Marshal(&cfg) + if err != nil { + return nil, err + } + + output := llb.Scratch(). + File(llb.Mkfile("/target", 0o600, []byte(target))). + File(llb.Mkfile("/config.json", 0o600, configBytes)) + + def, err := output.Marshal(ctx) + if err != nil { + return nil, err + } + + return c.Solve(ctx, gwclient.SolveRequest{ + Definition: def.ToPB(), + }) + }); err != nil { + bklog.L.WithError(err).Fatal("error running frontend") + os.Exit(137) + } +} diff --git a/test/helpers_test.go b/test/helpers_test.go index e4a0b069..25ecaef7 100644 --- a/test/helpers_test.go +++ b/test/helpers_test.go @@ -24,7 +24,8 @@ import ( ) const ( - phonyRef = "dalec/integration/frontend/phony" + phonyRef = "dalec/integration/frontend/phony" + phonySignerRef = "dalec/integration/signer/phony" ) func startTestSpan(ctx context.Context, t *testing.T) context.Context { diff --git a/test/main_test.go b/test/main_test.go index 8c618d19..0da8cf8e 100644 --- a/test/main_test.go +++ b/test/main_test.go @@ -76,6 +76,10 @@ func TestMain(m *testing.M) { panic(err) } + if err := testEnv.Load(ctx, phonySignerRef, fixtures.PhonySigner); err != nil { + panic(err) + } + return m.Run() } diff --git a/test/mariner2_test.go b/test/mariner2_test.go index 5655a118..c9d584f7 100644 --- a/test/mariner2_test.go +++ b/test/mariner2_test.go @@ -16,10 +16,10 @@ func TestMariner2(t *testing.T) { t.Parallel() ctx := startTestSpan(baseCtx, t) - testLinuxDistro(ctx, t, "mariner2/container") + testLinuxDistro(ctx, t, "mariner2/container", "mariner2/rpm") } -func testLinuxDistro(ctx context.Context, t *testing.T, buildTarget string) { +func testLinuxDistro(ctx context.Context, t *testing.T, buildTarget string, signTarget string) { t.Run("Fail when non-zero exit code during build", func(t *testing.T) { t.Parallel() spec := dalec.Spec{ @@ -87,6 +87,7 @@ func testLinuxDistro(ctx context.Context, t *testing.T, buildTarget string) { return gwclient.NewResult(), nil }) }) + t.Run("container", func(t *testing.T) { spec := dalec.Spec{ Name: "test-container-build", @@ -304,6 +305,51 @@ echo "$BAR" > bar.txt return gwclient.NewResult(), nil }) }) + + runTest := func(t *testing.T, f gwclient.BuildFunc) { + t.Helper() + ctx := startTestSpan(baseCtx, t) + testEnv.RunTest(ctx, t, f) + } + + t.Run("test signing", func(t *testing.T) { + t.Parallel() + spec := dalec.Spec{ + Name: "foo", + Version: "v0.0.1", + Description: "foo bar baz", + Website: "https://foo.bar.baz", + Revision: "1", + PackageConfig: &dalec.PackageConfig{ + Signer: &dalec.Frontend{ + Image: phonySignerRef, + }, + }, + Sources: map[string]dalec.Source{ + "foo": { + Inline: &dalec.SourceInline{ + File: &dalec.SourceInlineFile{ + Contents: "#!/usr/bin/env bash\necho \"hello, world!\"\n", + }, + }, + }, + }, + Build: dalec.ArtifactBuild{ + Steps: []dalec.BuildStep{ + { + Command: "/bin/true", + }, + }, + }, + Artifacts: dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo": {}, + }, + }, + } + + runTest(t, distroSigningTest(t, &spec, signTarget)) + }) } func validateFilePerms(ctx context.Context, ref gwclient.Reference, p string, expected os.FileMode) error { diff --git a/test/signing_test.go b/test/signing_test.go new file mode 100644 index 00000000..3297f46f --- /dev/null +++ b/test/signing_test.go @@ -0,0 +1,36 @@ +package test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/Azure/dalec" + gwclient "github.com/moby/buildkit/frontend/gateway/client" +) + +func distroSigningTest(t *testing.T, spec *dalec.Spec, buildTarget string) func(ctx context.Context, gwc gwclient.Client) (*gwclient.Result, error) { + return func(ctx context.Context, gwc gwclient.Client) (*gwclient.Result, error) { + topTgt, _, _ := strings.Cut(buildTarget, "/") + + sr := newSolveRequest(withSpec(ctx, t, spec), withBuildTarget(buildTarget)) + res, err := gwc.Solve(ctx, sr) + if err != nil { + t.Fatal(err) + } + + tgt := readFile(ctx, t, "/target", res) + cfg := readFile(ctx, t, "/config.json", res) + + if string(tgt) != topTgt { + t.Fatal(fmt.Errorf("target incorrect; either not sent to signer or not received back from signer")) + } + + if !strings.Contains(string(cfg), "linux") { + t.Fatal(fmt.Errorf("configuration incorrect")) + } + + return gwclient.NewResult(), nil + } +} diff --git a/test/windows_test.go b/test/windows_test.go index c04344d8..7b95c1c9 100644 --- a/test/windows_test.go +++ b/test/windows_test.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "strings" "testing" "github.com/Azure/dalec" + "github.com/moby/buildkit/client/llb" gwclient "github.com/moby/buildkit/frontend/gateway/client" moby_buildkit_v1_frontend "github.com/moby/buildkit/frontend/gateway/pb" ) @@ -248,4 +250,122 @@ echo "$BAR" > bar.txt return gwclient.NewResult(), nil }) }) + + runTest := func(t *testing.T, f gwclient.BuildFunc) { + t.Helper() + ctx := startTestSpan(baseCtx, t) + testEnv.RunTest(ctx, t, f) + } + + t.Run("test windows signing", func(t *testing.T) { + t.Parallel() + runTest(t, func(ctx context.Context, gwc gwclient.Client) (*gwclient.Result, error) { + spec := fillMetadata("foo", &dalec.Spec{ + Targets: map[string]dalec.Target{ + "windowscross": { + PackageConfig: &dalec.PackageConfig{ + Signer: &dalec.Frontend{ + Image: phonySignerRef, + }, + }, + }, + }, + Sources: map[string]dalec.Source{ + "foo": { + Inline: &dalec.SourceInline{ + File: &dalec.SourceInlineFile{ + Contents: "#!/usr/bin/env bash\necho \"hello, world!\"\n", + }, + }, + }, + }, + Build: dalec.ArtifactBuild{ + Steps: []dalec.BuildStep{ + { + Command: "/bin/true", + }, + }, + }, + Artifacts: dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo": {}, + }, + }, + }) + + zipperSpec := fillMetadata("bar", &dalec.Spec{ + Dependencies: &dalec.PackageDependencies{ + Runtime: map[string][]string{ + "unzip": {}, + }, + }, + }) + + sr := newSolveRequest(withSpec(ctx, t, zipperSpec), withBuildTarget("mariner2/container")) + zipper := reqToState(ctx, gwc, sr, t) + + sr = newSolveRequest(withSpec(ctx, t, spec), withBuildTarget("windowscross/zip")) + st := reqToState(ctx, gwc, sr, t) + + st = zipper.Run(llb.Args([]string{"bash", "-c", `for f in ./*.zip; do unzip "$f"; done`}), llb.Dir("/tmp/mnt")). + AddMount("/tmp/mnt", st) + + def, err := st.Marshal(ctx) + if err != nil { + t.Fatal(err) + } + + res, err := gwc.Solve(ctx, gwclient.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + t.Fatal(err) + } + + tgt := readFile(ctx, t, "/target", res) + cfg := readFile(ctx, t, "/config.json", res) + + if string(tgt) != "windowscross" { + t.Fatal(fmt.Errorf("target incorrect; either not sent to signer or not received back from signer")) + } + + if !strings.Contains(string(cfg), "windows") { + t.Fatal(fmt.Errorf("configuration incorrect")) + } + + return gwclient.NewResult(), nil + }) + }) +} + +func reqToState(ctx context.Context, gwc gwclient.Client, sr gwclient.SolveRequest, t *testing.T) llb.State { + res, err := gwc.Solve(ctx, sr) + if err != nil { + t.Fatal(err) + } + + ref, err := res.SingleRef() + if err != nil { + t.Fatal(err) + } + + st, err := ref.ToState() + if err != nil { + t.Fatal(err) + } + + return st +} + +func fillMetadata(fakename string, s *dalec.Spec) *dalec.Spec { + s.Name = "bar" + s.Version = "v0.0.1" + s.Description = "foo bar baz" + s.Website = "https://foo.bar.baz" + s.Revision = "1" + s.License = "MIT" + s.Vendor = "nothing" + s.Packager = "Bill Spummins" + + return s } diff --git a/website/docs/signing.md b/website/docs/signing.md new file mode 100644 index 00000000..414fdbb8 --- /dev/null +++ b/website/docs/signing.md @@ -0,0 +1,32 @@ +# Signing Packages + +Packages can be automatically signed using Dalec. To do this, you will +need to provide a signing frontend. There is an example in the test +code `test/signer/main.go`. Once that signing image has been built and +tagged, the following can be added to the spec to trigger the signing +operation: + +```yaml +targets: # Distro specific build requirements + mariner2: + package_config: + signer: + image: "ref/to/signing:image" + cmdline: "/signer" +``` + +This will send the artifacts (`.rpm`, `.deb`, or `.exe`) to the +signing frontend as the build context. + +The contract between dalec and the signing image is: + +1. The signing image will contain both the signing frontend, and any +additional tooling necessary to carry out the signing operation. +1. The `llb.State` corresponding the artifacts to be signed will be +provided as the build context. +1. Dalec will provide the value of `dalec.target` to the frontend as a +`FrontendOpt`. In the above example, this will be `mariner2`. +1. The response from the frontend will contain an `llb.State` that is +identical to the input `llb.State` in every way *except* that the +desired artifacts will be signed. + diff --git a/website/sidebars.ts b/website/sidebars.ts index 5f571a0a..d0a2f030 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -19,7 +19,8 @@ const sidebars: SidebarsConfig = { 'intro', 'editor-support', 'sources', - 'testing' + 'testing', + 'signing' ], }, ],