Merge pull request #8493 from DataDog/image-verifier-bindir-plugin

Add image verifier transfer service plugin system based on a binary directory
This commit is contained in:
Derek McGowan 2023-09-14 06:37:17 -07:00 committed by GitHub
commit 31b6cdfd10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1412 additions and 9 deletions

View File

@ -24,6 +24,7 @@ import (
_ "github.com/containerd/containerd/leases/plugin"
_ "github.com/containerd/containerd/metadata/plugin"
_ "github.com/containerd/containerd/pkg/nri/plugin"
_ "github.com/containerd/containerd/plugins/imageverifier"
_ "github.com/containerd/containerd/plugins/sandbox"
_ "github.com/containerd/containerd/plugins/streaming"
_ "github.com/containerd/containerd/plugins/transfer"

View File

@ -0,0 +1,51 @@
# Image Verification
The following covers the default "bindir" `ImageVerifier` plugin implementation.
To enable image verification, add a stanza like the following to the containerd config:
```yaml
[plugins]
[plugins."io.containerd.image-verifier.v1.bindir"]
bin_dir = "/opt/containerd/image-verifier/bin"
max_verifiers = 10
per_verifier_timeout = "10s"
```
All files in `bin_dir`, if it exists, must be verifier executables which conform to the following API.
## Image Verifier Binary API
### CLI Arguments
- `-name`: The given reference to the image that may be pulled.
- `-digest`: The resolved digest of the image that may be pulled.
- `-stdin-media-type`: The media type of the JSON data passed to stdin.
### Standard Input
A JSON encoded payload is passed to the verifier binary's standard input. The
media type of this payload is specified by the `-stdin-media-type` CLI
argument, and may change in future versions of containerd. Currently, the
payload has a media type of `application/vnd.oci.descriptor.v1+json` and
represents the OCI Content Descriptor of the image that may be pulled. See
[the OCI specification](https://github.com/opencontainers/image-spec/blob/main/descriptor.md)
for more details.
### Image Pull Judgement
Print to standard output a reason for the image pull judgement.
Return an exit code of 0 to allow the image to be pulled and any other exit code to block the image from being pulled.
## Image Verifier Caller Contract
- If `bin_dir` does not exist or contains no files, the image verifier does not block image pulls.
- An image is pulled only if all verifiers that are called return an "ok" judgement (exit with status code 0). In other words, image pull judgements are combined with an `AND` operator.
- If any verifiers exceeds the `per_verifier_timeout` or fails to exec, the verification fails with an error and a `nil` judgement is returned.
- If `max_verifiers < 0`, there is no imposed limit on the number of image verifiers called.
- If `max_verifiers >= 0`, there is a limit imposed on the number of image verifiers called. The entries in `bin_dir` are lexicographically sorted by name, and the first `n = max_verifiers` of the verifiers will be called, and the rest will be skipped.
- There is no guarantee for the order of execution of verifier binaries.
- Standard error output of verifier binaries is logged at debug level by containerd, subject to truncation.
- Standard output of verifier binaries (the "reason" for the judgement) is subject to truncation.
- System resources used by verifier binaries are currently accounted for in and constrained by containerd's own cgroup, but this is subject to change.

View File

@ -0,0 +1,259 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package bindir
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/containerd/containerd/log"
"github.com/containerd/containerd/pkg/imageverifier"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/sirupsen/logrus"
)
const outputLimitBytes = 1 << 15 // 32 KiB
type Config struct {
BinDir string `toml:"bin_dir"`
MaxVerifiers int `toml:"max_verifiers"`
PerVerifierTimeout time.Duration `toml:"per_verifier_timeout"`
}
type ImageVerifier struct {
config *Config
}
var _ imageverifier.ImageVerifier = (*ImageVerifier)(nil)
func NewImageVerifier(c *Config) *ImageVerifier {
return &ImageVerifier{
config: c,
}
}
func (v *ImageVerifier) VerifyImage(ctx context.Context, name string, desc ocispec.Descriptor) (*imageverifier.Judgement, error) {
// os.ReadDir sorts entries by name.
entries, err := os.ReadDir(v.config.BinDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return &imageverifier.Judgement{
OK: true,
Reason: fmt.Sprintf("image verifier directory %v does not exist", v.config.BinDir),
}, nil
}
return nil, fmt.Errorf("failed to list directory contents: %w", err)
}
if len(entries) == 0 {
return &imageverifier.Judgement{
OK: true,
Reason: fmt.Sprintf("no image verifier binaries found in %v", v.config.BinDir),
}, nil
}
reason := &strings.Builder{}
for i, entry := range entries {
if (i+1) > v.config.MaxVerifiers && v.config.MaxVerifiers >= 0 {
log.G(ctx).Warnf("image verifiers are being skipped since directory %v has %v entries, more than configured max of %v verifiers", v.config.BinDir, len(entries), v.config.MaxVerifiers)
break
}
bin := entry.Name()
start := time.Now()
exitCode, vr, err := v.runVerifier(ctx, bin, name, desc)
runtime := time.Since(start)
if err != nil {
return nil, fmt.Errorf("failed to call verifier %v (runtime %v): %w", bin, runtime, err)
}
if exitCode != 0 {
return &imageverifier.Judgement{
OK: false,
Reason: fmt.Sprintf("verifier %v rejected image (exit code %v): %v", bin, exitCode, vr),
}, nil
}
if i > 0 {
reason.WriteString(", ")
}
reason.WriteString(fmt.Sprintf("%v => %v", bin, vr))
}
return &imageverifier.Judgement{
OK: true,
Reason: reason.String(),
}, nil
}
func (v *ImageVerifier) runVerifier(ctx context.Context, bin string, imageName string, desc ocispec.Descriptor) (exitCode int, reason string, err error) {
ctx, cancel := context.WithTimeout(ctx, v.config.PerVerifierTimeout)
defer cancel()
binPath := filepath.Join(v.config.BinDir, bin)
args := []string{
"-name", imageName,
"-digest", desc.Digest.String(),
"-stdin-media-type", ocispec.MediaTypeDescriptor,
}
cmd := exec.CommandContext(ctx, binPath, args...)
// We construct our own pipes instead of using the default StdinPipe,
// StoutPipe, and StderrPipe in order to set timeouts on reads and writes.
stdinRead, stdinWrite, err := os.Pipe()
if err != nil {
return -1, "", err
}
cmd.Stdin = stdinRead
defer stdinRead.Close()
defer stdinWrite.Close()
stdoutRead, stdoutWrite, err := os.Pipe()
if err != nil {
return -1, "", err
}
cmd.Stdout = stdoutWrite
defer stdoutRead.Close()
defer stdoutWrite.Close()
stderrRead, stderrWrite, err := os.Pipe()
if err != nil {
return -1, "", err
}
cmd.Stderr = stderrWrite
defer stderrRead.Close()
defer stderrWrite.Close()
// Close parent ends of pipes on timeout. Without this, I/O may hang in the
// parent process.
if d, ok := ctx.Deadline(); ok {
stdinWrite.SetDeadline(d)
stdoutRead.SetDeadline(d)
stderrRead.SetDeadline(d)
}
// Finish configuring, and then fork & exec the child process.
p, err := startProcess(ctx, cmd)
if err != nil {
return -1, "", err
}
defer p.cleanup(ctx)
// Close the child ends of the pipes in the parent process.
stdinRead.Close()
stdoutWrite.Close()
stderrWrite.Close()
// Write the descriptor to stdin.
go func() {
// Descriptors are usually small enough to fit in a pipe buffer (which is
// often 64 KiB on Linux) so this write usually won't block on the child
// process reading stdin. However, synchronously writing to stdin may cause
// the parent to block if the descriptor is larger than the pipe buffer and
// the child process doesn't read stdin. Therefore, we write to stdin
// asynchronously, limited by the stdinWrite deadline set above.
err := json.NewEncoder(stdinWrite).Encode(desc)
if err != nil {
// This may error out with a "broken pipe" error if the descriptor is
// larger than the pipe buffer and the child process does not read all
// of stdin.
log.G(ctx).WithError(err).Warn("failed to completely write descriptor to stdin")
}
stdinWrite.Close()
}()
// Pipe verifier stderr lines to debug logs.
stderrLog := log.G(ctx).Logger.WithFields(logrus.Fields{
"image_verifier": bin,
"stream": "stderr",
})
stderrLogDone := make(chan struct{})
go func() {
defer close(stderrLogDone)
defer stderrRead.Close()
lr := &io.LimitedReader{
R: stderrRead,
N: outputLimitBytes,
}
s := bufio.NewScanner(lr)
for s.Scan() {
stderrLog.Debug(s.Text())
}
if err := s.Err(); err != nil {
stderrLog.WithError(err).Debug("error logging image verifier stderr")
}
if lr.N == 0 {
// Peek ahead to see if stderr reader was truncated.
b := make([]byte, 1)
if n, _ := stderrRead.Read(b); n > 0 {
stderrLog.Debug("(previous logs may be truncated)")
}
}
// Discard the truncated part of stderr. Doing this rather than closing the
// reader avoids broken pipe errors. This is bounded by the stderrRead
// deadline.
if _, err := io.Copy(io.Discard, stderrRead); err != nil {
log.G(ctx).WithError(err).Error("error flushing stderr")
}
}()
stdout, err := io.ReadAll(io.LimitReader(stdoutRead, outputLimitBytes))
if err != nil {
log.G(ctx).WithError(err).Error("error reading stdout")
} else {
m := strings.Builder{}
m.WriteString(strings.TrimSpace(string(stdout)))
// Peek ahead to see if stdout is truncated.
b := make([]byte, 1)
if n, _ := stdoutRead.Read(b); n > 0 {
m.WriteString("(stdout truncated)")
}
reason = m.String()
}
// Discard the truncated part of stdout. Doing this rather than closing the
// reader avoids broken pipe errors. This is bounded by the stdoutRead
// deadline.
if _, err := io.Copy(io.Discard, stdoutRead); err != nil {
log.G(ctx).WithError(err).Error("error flushing stdout")
}
stdoutRead.Close()
<-stderrLogDone
if err := cmd.Wait(); err != nil {
if ee := (&exec.ExitError{}); errors.As(err, &ee) && ee.ProcessState.Exited() {
return ee.ProcessState.ExitCode(), reason, nil
}
return -1, "", fmt.Errorf("waiting on command to exit: %v", err)
}
return cmd.ProcessState.ExitCode(), reason, nil
}

View File

@ -0,0 +1,419 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package bindir
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"text/template"
"time"
"github.com/containerd/containerd/log"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// buildGoVerifiers uses the local Go toolchain to build each of the standalone
// main package source files in srcDir into binaries placed in binDir.
func buildGoVerifiers(t *testing.T, srcsDir string, binDir string) {
srcs, err := os.ReadDir(srcsDir)
require.NoError(t, err)
for _, srcFile := range srcs {
// Build the source into a Go binary.
src := filepath.Join(srcsDir, srcFile.Name())
bin := filepath.Join(binDir, strings.Split(srcFile.Name(), ".")[0]+exeIfWindows())
cmd := exec.Command("go", "build", "-o", bin, src)
code, err := os.ReadFile(src)
require.NoError(t, err)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("failed to build test verifier %s: %v\n%s\nGo code:\n%s", src, err, out, code)
}
}
}
func exeIfWindows() string {
// The command `go build -o abc abc.go` creates abc.exe on Windows.
if runtime.GOOS == "windows" {
return ".exe"
}
return ""
}
// newBinDir creates a temporary directory and copies each of the selected bins
// fromSrcDir into that directory. The copied verifier binaries are given names
// such that they sort (and therefore execute) in the order that bins is given.
func newBinDir(t *testing.T, srcDir string, bins ...string) string {
binDir := t.TempDir()
for i, bin := range bins {
src, err := os.Open(filepath.Join(srcDir, bin+exeIfWindows()))
require.NoError(t, err)
defer src.Close()
dst, err := os.OpenFile(filepath.Join(binDir, fmt.Sprintf("verifier-%v%v", i, exeIfWindows())), os.O_WRONLY|os.O_CREATE, 0755)
require.NoError(t, err)
defer dst.Close()
_, err = io.Copy(dst, src)
require.NoError(t, err)
}
return binDir
}
func TestBinDirVerifyImage(t *testing.T) {
// Enable debug logs to easily see stderr for verifiers upon test failure.
logger := log.L.Dup()
logger.Logger.SetLevel(logrus.DebugLevel)
ctx := log.WithLogger(context.Background(), logger)
// Build verifiers from plain Go file.
allBinsDir := t.TempDir()
buildGoVerifiers(t, "testdata/verifiers", allBinsDir)
// Build verifiers from templates.
data := struct {
ArgsFile string
StdinFile string
}{
ArgsFile: filepath.Join(t.TempDir(), "args.txt"),
StdinFile: filepath.Join(t.TempDir(), "stdin.txt"),
}
tmplDir := "testdata/verifier_templates"
templates, err := os.ReadDir(tmplDir)
require.NoError(t, err)
renderedVerifierTmplDir := t.TempDir()
for _, tmplFile := range templates {
tmplPath := filepath.Join(tmplDir, tmplFile.Name())
tmpl, err := template.New(tmplFile.Name()).ParseFiles(tmplPath)
require.NoError(t, err)
goFileName := strings.ReplaceAll(tmplFile.Name(), ".go.tmpl", ".go")
f, err := os.Create(filepath.Join(renderedVerifierTmplDir, goFileName))
require.NoError(t, err)
defer f.Close()
require.NoError(t, tmpl.Execute(f, data))
f.Close()
}
buildGoVerifiers(t, renderedVerifierTmplDir, allBinsDir)
// Actual tests begin here.
t.Run("proper input/output management", func(t *testing.T) {
binDir := newBinDir(t, allBinsDir,
"verifier_test_input_output_management",
)
v := NewImageVerifier(&Config{
BinDir: binDir,
MaxVerifiers: -1,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{
Digest: "sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4",
MediaType: "application/vnd.docker.distribution.manifest.list.v2+json",
Size: 2048,
Annotations: map[string]string{"a": "b"},
})
assert.NoError(t, err)
assert.True(t, j.OK)
assert.Equal(t, fmt.Sprintf("verifier-0%[1]v => Reason A line 1\nReason A line 2", exeIfWindows()), j.Reason)
b, err := os.ReadFile(data.ArgsFile)
require.NoError(t, err)
assert.Equal(t, "-name registry.example.com/image:abc -digest sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4 -stdin-media-type application/vnd.oci.descriptor.v1+json", string(b))
b, err = os.ReadFile(data.StdinFile)
require.NoError(t, err)
assert.Equal(t, `{"mediaType":"application/vnd.docker.distribution.manifest.list.v2+json","digest":"sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4","size":2048,"annotations":{"a":"b"}}`, strings.TrimSpace(string(b)))
})
t.Run("large output is truncated", func(t *testing.T) {
bins := []string{
"large_stdout",
"large_stdout_chunked",
"large_stderr",
"large_stderr_chunked",
}
binDir := newBinDir(t, allBinsDir, bins...)
v := NewImageVerifier(&Config{
BinDir: binDir,
MaxVerifiers: -1,
PerVerifierTimeout: 30 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{})
assert.NoError(t, err)
assert.True(t, j.OK, "expected OK, got not OK with reason: %v", j.Reason)
assert.Less(t, len(j.Reason), len(bins)*(outputLimitBytes+1024), "reason is: %v", j.Reason) // 1024 leaves margin for the formatting around the reason.
})
t.Run("missing directory", func(t *testing.T) {
v := NewImageVerifier(&Config{
BinDir: filepath.Join(t.TempDir(), "missing_directory"),
MaxVerifiers: 10,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{})
assert.NoError(t, err)
assert.True(t, j.OK)
assert.NotEmpty(t, j.Reason)
})
t.Run("empty directory", func(t *testing.T) {
v := NewImageVerifier(&Config{
BinDir: t.TempDir(),
MaxVerifiers: 10,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{})
assert.NoError(t, err)
assert.True(t, j.OK)
assert.NotEmpty(t, j.Reason)
})
t.Run("max verifiers = 0", func(t *testing.T) {
binDir := newBinDir(t, allBinsDir,
"reject_reason_d", // This never runs.
)
v := NewImageVerifier(&Config{
BinDir: binDir,
MaxVerifiers: 0,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{})
assert.NoError(t, err)
assert.True(t, j.OK)
assert.Empty(t, j.Reason)
})
t.Run("max verifiers = 1", func(t *testing.T) {
binDir := newBinDir(t, allBinsDir,
"accept_reason_a",
"reject_reason_d", // This never runs.
)
v := NewImageVerifier(&Config{
BinDir: binDir,
MaxVerifiers: 1,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{})
assert.NoError(t, err)
assert.True(t, j.OK)
assert.NotEmpty(t, j.Reason)
})
t.Run("max verifiers = 2", func(t *testing.T) {
binDir := newBinDir(t, allBinsDir,
"accept_reason_a",
"accept_reason_a",
"reject_reason_d", // This never runs.
)
v := NewImageVerifier(&Config{
BinDir: binDir,
MaxVerifiers: 2,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{})
assert.NoError(t, err)
assert.True(t, j.OK)
assert.NotEmpty(t, j.Reason)
})
t.Run("max verifiers = 3, all accept", func(t *testing.T) {
binDir := newBinDir(t, allBinsDir,
"accept_reason_a",
"accept_reason_b",
"accept_reason_c",
)
v := NewImageVerifier(&Config{
BinDir: binDir,
MaxVerifiers: 3,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{})
assert.NoError(t, err)
assert.True(t, j.OK)
assert.Equal(t, fmt.Sprintf("verifier-0%[1]v => Reason A, verifier-1%[1]v => Reason B, verifier-2%[1]v => Reason C", exeIfWindows()), j.Reason)
})
t.Run("max verifiers = 3, with reject", func(t *testing.T) {
binDir := newBinDir(t, allBinsDir,
"accept_reason_a",
"accept_reason_b",
"reject_reason_d",
)
v := NewImageVerifier(&Config{
BinDir: binDir,
MaxVerifiers: 3,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{})
assert.NoError(t, err)
assert.False(t, j.OK)
assert.Equal(t, fmt.Sprintf("verifier verifier-2%[1]v rejected image (exit code 1): Reason D", exeIfWindows()), j.Reason)
})
t.Run("max verifiers = -1, all accept", func(t *testing.T) {
binDir := newBinDir(t, allBinsDir,
"accept_reason_a",
"accept_reason_b",
"accept_reason_c",
)
v := NewImageVerifier(&Config{
BinDir: binDir,
MaxVerifiers: -1,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{})
assert.NoError(t, err)
assert.True(t, j.OK)
assert.Equal(t, fmt.Sprintf("verifier-0%[1]v => Reason A, verifier-1%[1]v => Reason B, verifier-2%[1]v => Reason C", exeIfWindows()), j.Reason)
})
t.Run("max verifiers = -1, with reject", func(t *testing.T) {
binDir := newBinDir(t, allBinsDir,
"accept_reason_a",
"accept_reason_b",
"reject_reason_d",
)
v := NewImageVerifier(&Config{
BinDir: binDir,
MaxVerifiers: -1,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{})
assert.NoError(t, err)
assert.False(t, j.OK)
assert.Equal(t, fmt.Sprintf("verifier verifier-2%[1]v rejected image (exit code 1): Reason D", exeIfWindows()), j.Reason)
})
t.Run("max verifiers = -1, with timeout", func(t *testing.T) {
binDir := newBinDir(t, allBinsDir,
"accept_reason_a",
"accept_reason_b",
"slow_child_process",
)
v := NewImageVerifier(&Config{
BinDir: binDir,
MaxVerifiers: -1,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{})
if runtime.GOOS == "windows" {
assert.NoError(t, err)
assert.False(t, j.OK)
assert.Equal(t, "verifier verifier-2.exe rejected image (exit code 1): ", j.Reason)
} else {
assert.ErrorContains(t, err, "signal: killed")
assert.Nil(t, j)
}
command := []string{"ps", "ax"}
if runtime.GOOS == "windows" {
command = []string{"tasklist"}
}
b, err := exec.Command(command[0], command[1:]...).CombinedOutput()
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "verifier-") {
t.Fatalf("killing the verifier binary didn't kill all its children:\n%v", string(b))
}
})
t.Run("max verifiers = -1, with exec failure", func(t *testing.T) {
binDir := t.TempDir()
err := os.WriteFile(filepath.Join(binDir, "bad.sh"), []byte("BAD"), 0744)
require.NoError(t, err)
v := NewImageVerifier(&Config{
BinDir: binDir,
MaxVerifiers: -1,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{})
assert.Error(t, err)
assert.Nil(t, j)
})
t.Run("descriptor larger than linux pipe buffer, verifier doesn't read stdin", func(t *testing.T) {
binDir := newBinDir(t, allBinsDir,
"accept_reason_a",
)
v := NewImageVerifier(&Config{
BinDir: binDir,
MaxVerifiers: 1,
PerVerifierTimeout: 5 * time.Second,
})
j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{
Digest: "sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4",
MediaType: "application/vnd.docker.distribution.manifest.list.v2+json",
Size: 2048,
Annotations: map[string]string{
// Pipe buffer is usually 64KiB.
"large_payload": strings.Repeat("0", 2*64*(1<<10)),
},
})
// Should see a log like the following, but verification still succeeds:
// time="2023-09-05T11:15:50-04:00" level=warning msg="failed to completely write descriptor to stdin" error="write |1: broken pipe"
assert.NoError(t, err)
assert.True(t, j.OK)
assert.NotEmpty(t, j.Reason)
})
}

View File

@ -0,0 +1,55 @@
//go:build !windows
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package bindir
import (
"context"
"fmt"
"os/exec"
"golang.org/x/sys/unix"
)
type process struct {
cmd *exec.Cmd
}
// Configure the verifier command so that killing it kills all child
// processes of the verifier process.
func startProcess(ctx context.Context, cmd *exec.Cmd) (*process, error) {
// Assign the verifier a new process group so that killing its process group
// in Cancel() doesn't kill the parent process (containerd).
cmd.SysProcAttr = &unix.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
// Passing a negative PID causes kill(2) to kill all processes in the
// process group whose ID is cmd.Process.Pid.
return unix.Kill(-cmd.Process.Pid, unix.SIGKILL)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("starting process: %w", err)
}
return &process{
cmd: cmd,
}, nil
}
func (p *process) cleanup(ctx context.Context) {}

View File

@ -0,0 +1,105 @@
//go:build windows
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package bindir
import (
"context"
"fmt"
"os/exec"
"unsafe"
"github.com/containerd/containerd/log"
"golang.org/x/sys/windows"
)
type process struct {
cmd *exec.Cmd
jobHandle *windows.Handle
processHandle *windows.Handle
}
// Configure the verifier command so that killing it kills all child
// processes of the verifier process.
//
// Job/process management based on:
// https://devblogs.microsoft.com/oldnewthing/20131209-00/?p=2433
func startProcess(ctx context.Context, cmd *exec.Cmd) (*process, error) {
p := &process{
cmd: cmd,
}
jobHandle, err := windows.CreateJobObject(nil, nil)
if err != nil {
return nil, fmt.Errorf("creating job object: %w", err)
}
p.jobHandle = &jobHandle
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
}
_, err = windows.SetInformationJobObject(
jobHandle,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
)
if err != nil {
p.cleanup(ctx)
return nil, fmt.Errorf("setting limits for job object: %w", err)
}
if err := cmd.Start(); err != nil {
p.cleanup(ctx)
return nil, fmt.Errorf("starting process: %w", err)
}
processHandle, err := windows.OpenProcess(
windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE,
false,
uint32(cmd.Process.Pid),
)
if err != nil {
return nil, fmt.Errorf("getting handle for verifier process: %w", err)
}
p.processHandle = &processHandle
err = windows.AssignProcessToJobObject(jobHandle, processHandle)
if err != nil {
p.cleanup(ctx)
return nil, fmt.Errorf("associating new process to job object: %w", err)
}
return p, nil
}
func (p *process) cleanup(ctx context.Context) {
if p.jobHandle != nil {
if err := windows.CloseHandle(*p.jobHandle); err != nil {
log.G(ctx).WithError(err).Error("failed to close job handle")
}
}
if p.processHandle != nil {
if err := windows.CloseHandle(*p.processHandle); err != nil {
log.G(ctx).WithError(err).Error("failed to close process handle")
}
}
}

View File

@ -0,0 +1,45 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"io"
"os"
"strings"
)
func main() {
err := os.WriteFile(`{{.ArgsFile}}`, []byte(strings.Join(os.Args[1:], " ")), 0644)
if err != nil {
panic(err)
}
stdin, err := io.ReadAll(os.Stdin)
if err != nil {
panic(err)
}
err = os.WriteFile(`{{.StdinFile}}`, stdin, 0644)
if err != nil {
panic(err)
}
fmt.Println("Reason A line 1")
fmt.Fprintln(os.Stderr, "Debug A line 1")
fmt.Println("Reason A line 2")
fmt.Fprintln(os.Stderr, "Debug A line 2")
}

View File

@ -0,0 +1,23 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import "fmt"
func main() {
fmt.Println("Reason A")
}

View File

@ -0,0 +1,23 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import "fmt"
func main() {
fmt.Println("Reason B")
}

View File

@ -0,0 +1,23 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import "fmt"
func main() {
fmt.Println("Reason C")
}

View File

@ -0,0 +1,35 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"os"
"strings"
)
func main() {
n := 50000
fmt.Fprintf(os.Stderr, "attempting to write %v bytes to stderr\n", n)
wrote, err := fmt.Fprintf(os.Stderr, strings.Repeat("A", n))
if err != nil {
fmt.Fprintf(os.Stderr, "got error writing to stderr: %v\n", err)
}
fmt.Fprintf(os.Stderr, "wrote %v bytes to stderr\n", wrote)
}

View File

@ -0,0 +1,45 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"os"
)
func main() {
n := 500000
fmt.Fprintf(os.Stderr, "attempting to write %v bytes to stderr\n", n)
// Writing this all in one fmt.Fprintf has a different interaction with
// stderr pipe buffering than writing one byte at a time.
wrote := 0
for i := 0; i < n; i++ {
w, err := fmt.Fprintf(os.Stderr, "A")
if err != nil {
fmt.Fprintf(os.Stderr, "got error writing to stderr: %v\n", err)
}
wrote += w
if i%10000 == 0 {
fmt.Fprintf(os.Stderr, "progress: wrote %v bytes to stderr\n", wrote)
}
}
fmt.Fprintf(os.Stderr, "wrote %v bytes to stderr\n", wrote)
}

View File

@ -0,0 +1,35 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"os"
"strings"
)
func main() {
n := 50000
fmt.Fprintf(os.Stderr, "attempting to write %v bytes to stdout\n", n)
wrote, err := fmt.Print(strings.Repeat("A", n))
if err != nil {
fmt.Fprintf(os.Stderr, "got error writing to stdout: %v\n", err)
}
fmt.Fprintf(os.Stderr, "wrote %v bytes to stdout\n", wrote)
}

View File

@ -0,0 +1,45 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"os"
)
func main() {
n := 500000
fmt.Fprintf(os.Stderr, "attempting to write %v bytes to stdout\n", n)
// Writing this all in one fmt.Print has a different interaction with stdout
// pipe buffering than writing one byte at a time.
wrote := 0
for i := 0; i < n; i++ {
w, err := fmt.Print("A")
if err != nil {
fmt.Fprintf(os.Stderr, "got error writing to stdout: %v\n", err)
}
wrote += w
if i%10000 == 0 {
fmt.Fprintf(os.Stderr, "progress: wrote %v bytes to stdout\n", wrote)
}
}
fmt.Fprintf(os.Stderr, "wrote %v bytes to stdout\n", wrote)
}

View File

@ -0,0 +1,27 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("Reason D")
os.Exit(1)
}

View File

@ -0,0 +1,40 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"os"
"os/exec"
)
func main() {
// Launch a slow child process by re-executing this binary with the -sleep-forever flag.
if len(os.Args) == 2 && os.Args[1] == "-sleep-forever" {
fmt.Println("sleeping forever...")
for {
}
}
thisBin := os.Args[0]
cmd := exec.Command(thisBin, "-sleep-forever")
b, err := cmd.CombinedOutput()
fmt.Println(string(b))
if err != nil {
panic(err)
}
}

View File

@ -0,0 +1,32 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package imageverifier
import (
"context"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
type ImageVerifier interface {
VerifyImage(ctx context.Context, name string, desc ocispec.Descriptor) (*Judgement, error)
}
type Judgement struct {
OK bool
Reason string
}

View File

@ -30,6 +30,7 @@ import (
"github.com/containerd/containerd/remotes"
"github.com/containerd/containerd/remotes/docker"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/sirupsen/logrus"
)
func (ts *localTransferService) pull(ctx context.Context, ir transfer.ImageFetcher, is transfer.ImageStorer, tops *transfer.Config) error {
@ -54,6 +55,32 @@ func (ts *localTransferService) pull(ctx context.Context, ir transfer.ImageFetch
return fmt.Errorf("schema 1 image manifests are no longer supported: %w", errdefs.ErrInvalidArgument)
}
// Verify image before pulling.
for vfName, vf := range ts.verifiers {
log := log.G(ctx).WithFields(logrus.Fields{
"name": name,
"digest": desc.Digest.String(),
"verifier": vfName,
})
log.Debug("Verifying image pull")
jdg, err := vf.VerifyImage(ctx, name, desc)
if err != nil {
log.WithError(err).Error("No judgement received from verifier")
return fmt.Errorf("blocking pull of %v with digest %v: image verifier %v returned error: %w", name, desc.Digest.String(), vfName, err)
}
log = log.WithFields(logrus.Fields{
"ok": jdg.OK,
"reason": jdg.Reason,
})
if !jdg.OK {
log.Warn("Image verifier blocked pull")
return fmt.Errorf("image verifier %s blocked pull of %v with digest %v for reason: %v", vfName, name, desc.Digest.String(), jdg.Reason)
}
log.Debug("Image verifier allowed pull")
}
// TODO: Handle already exists
if tops.Progress != nil {
tops.Progress(transfer.Progress{

View File

@ -29,15 +29,17 @@ import (
"github.com/containerd/containerd/errdefs"
"github.com/containerd/containerd/images"
"github.com/containerd/containerd/leases"
"github.com/containerd/containerd/pkg/imageverifier"
"github.com/containerd/containerd/pkg/kmutex"
"github.com/containerd/containerd/pkg/transfer"
"github.com/containerd/containerd/pkg/unpack"
)
type localTransferService struct {
leases leases.Manager
content content.Store
images images.Store
leases leases.Manager
content content.Store
images images.Store
verifiers map[string]imageverifier.ImageVerifier
// limiter for upload
limiterU *semaphore.Weighted
// limiter for download operation
@ -45,12 +47,13 @@ type localTransferService struct {
config TransferConfig
}
func NewTransferService(lm leases.Manager, cs content.Store, is images.Store, tc *TransferConfig) transfer.Transferrer {
func NewTransferService(lm leases.Manager, cs content.Store, is images.Store, vfs map[string]imageverifier.ImageVerifier, tc *TransferConfig) transfer.Transferrer {
ts := &localTransferService{
leases: lm,
content: cs,
images: is,
config: *tc,
leases: lm,
content: cs,
images: is,
verifiers: vfs,
config: *tc,
}
if tc.MaxConcurrentUploadedLayers > 0 {
ts.limiterU = semaphore.NewWeighted(int64(tc.MaxConcurrentUploadedLayers))
@ -88,6 +91,7 @@ func (ts *localTransferService) Transfer(ctx context.Context, src interface{}, d
case transfer.ImageExportStreamer:
return ts.echo(ctx, s, d, topts)
case transfer.ImageStorer:
// TODO: verify imports with ImageVerifiers?
return ts.importStream(ctx, s, d, topts)
}
}

View File

@ -90,6 +90,8 @@ const (
SandboxStorePlugin Type = "io.containerd.sandbox.store.v1"
// SandboxControllerPlugin implements a sandbox controller
SandboxControllerPlugin Type = "io.containerd.sandbox.controller.v1"
// ImageVerifierPlugin implements an image verifier service
ImageVerifierPlugin Type = "io.containerd.image-verifier.v1"
)
const (

View File

@ -0,0 +1,21 @@
//go:build !windows
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package imageverifier
var defaultPath = "/opt/containerd/image-verifier/bin"

View File

@ -0,0 +1,25 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package imageverifier
import (
"path/filepath"
"github.com/containerd/containerd/defaults"
)
var defaultPath = filepath.Join(defaults.DefaultRootDir, "opt", "image-verifier", "bin")

View File

@ -0,0 +1,45 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package imageverifier
import (
"time"
"github.com/containerd/containerd/pkg/imageverifier/bindir"
"github.com/containerd/containerd/plugin"
)
// Register default image verifier service plugin
func init() {
plugin.Register(&plugin.Registration{
Type: plugin.ImageVerifierPlugin,
ID: "bindir",
Config: defaultConfig(),
InitFn: func(ic *plugin.InitContext) (interface{}, error) {
cfg := ic.Config.(*bindir.Config)
return bindir.NewImageVerifier(cfg), nil
},
})
}
func defaultConfig() *bindir.Config {
return &bindir.Config{
BinDir: defaultPath,
MaxVerifiers: 10,
PerVerifierTimeout: 10 * time.Second,
}
}

View File

@ -25,6 +25,7 @@ import (
"github.com/containerd/containerd/leases"
"github.com/containerd/containerd/log"
"github.com/containerd/containerd/metadata"
"github.com/containerd/containerd/pkg/imageverifier"
"github.com/containerd/containerd/pkg/transfer/local"
"github.com/containerd/containerd/pkg/unpack"
"github.com/containerd/containerd/platforms"
@ -45,6 +46,7 @@ func init() {
plugin.LeasePlugin,
plugin.MetadataPlugin,
plugin.DiffPlugin,
plugin.ImageVerifierPlugin,
},
Config: defaultConfig(),
InitFn: func(ic *plugin.InitContext) (interface{}, error) {
@ -59,6 +61,20 @@ func init() {
return nil, err
}
vfs := make(map[string]imageverifier.ImageVerifier)
vps, err := ic.GetByType(plugin.ImageVerifierPlugin)
if err != nil {
return nil, err
}
for name, vp := range vps {
inst, err := vp.Instance()
if err != nil {
return nil, err
}
vfs[name] = inst.(imageverifier.ImageVerifier)
}
// Set configuration based on default or user input
var lc local.TransferConfig
lc.MaxConcurrentDownloads = config.MaxConcurrentDownloads
@ -126,7 +142,7 @@ func init() {
}
lc.RegistryConfigPath = config.RegistryConfigPath
return local.NewTransferService(l.(leases.Manager), ms.ContentStore(), metadata.NewImageStore(ms), &lc), nil
return local.NewTransferService(l.(leases.Manager), ms.ContentStore(), metadata.NewImageStore(ms), vfs, &lc), nil
},
})
}