Skip to content

Commit ac1d556

Browse files
Add image verifier transfer service plugin system based on a binary directory
Signed-off-by: Ethan Lowman <[email protected]>
1 parent 5c37d38 commit ac1d556

24 files changed

+1412
-9
lines changed

cmd/containerd/builtins/builtins.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
_ "github.com/containerd/containerd/leases/plugin"
2525
_ "github.com/containerd/containerd/metadata/plugin"
2626
_ "github.com/containerd/containerd/pkg/nri/plugin"
27+
_ "github.com/containerd/containerd/plugins/imageverifier"
2728
_ "github.com/containerd/containerd/plugins/sandbox"
2829
_ "github.com/containerd/containerd/plugins/streaming"
2930
_ "github.com/containerd/containerd/plugins/transfer"

docs/image-verification.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Image Verification
2+
3+
The following covers the default "bindir" `ImageVerifier` plugin implementation.
4+
5+
To enable image verification, add a stanza like the following to the containerd config:
6+
7+
```yaml
8+
[plugins]
9+
[plugins."io.containerd.image-verifier.v1.bindir"]
10+
bin_dir = "/opt/containerd/image-verifier/bin"
11+
max_verifiers = 10
12+
per_verifier_timeout = "10s"
13+
```
14+
15+
All files in `bin_dir`, if it exists, must be verifier executables which conform to the following API.
16+
17+
## Image Verifier Binary API
18+
19+
### CLI Arguments
20+
21+
- `-name`: The given reference to the image that may be pulled.
22+
- `-digest`: The resolved digest of the image that may be pulled.
23+
- `-stdin-media-type`: The media type of the JSON data passed to stdin.
24+
25+
### Standard Input
26+
27+
A JSON encoded payload is passed to the verifier binary's standard input. The
28+
media type of this payload is specified by the `-stdin-media-type` CLI
29+
argument, and may change in future versions of containerd. Currently, the
30+
payload has a media type of `application/vnd.oci.descriptor.v1+json` and
31+
represents the OCI Content Descriptor of the image that may be pulled. See
32+
[the OCI specification](https://github.com/opencontainers/image-spec/blob/main/descriptor.md)
33+
for more details.
34+
35+
### Image Pull Judgement
36+
37+
Print to standard output a reason for the image pull judgement.
38+
39+
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.
40+
41+
## Image Verifier Caller Contract
42+
43+
- If `bin_dir` does not exist or contains no files, the image verifier does not block image pulls.
44+
- 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.
45+
- If any verifiers exceeds the `per_verifier_timeout` or fails to exec, the verification fails with an error and a `nil` judgement is returned.
46+
- If `max_verifiers < 0`, there is no imposed limit on the number of image verifiers called.
47+
- 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.
48+
- There is no guarantee for the order of execution of verifier binaries.
49+
- Standard error output of verifier binaries is logged at debug level by containerd, subject to truncation.
50+
- Standard output of verifier binaries (the "reason" for the judgement) is subject to truncation.
51+
- System resources used by verifier binaries are currently accounted for in and constrained by containerd's own cgroup, but this is subject to change.

pkg/imageverifier/bindir/bindir.go

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package bindir
18+
19+
import (
20+
"bufio"
21+
"context"
22+
"encoding/json"
23+
"errors"
24+
"fmt"
25+
"io"
26+
"os"
27+
"os/exec"
28+
"path/filepath"
29+
"strings"
30+
"time"
31+
32+
"github.com/containerd/containerd/log"
33+
"github.com/containerd/containerd/pkg/imageverifier"
34+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
35+
"github.com/sirupsen/logrus"
36+
)
37+
38+
const outputLimitBytes = 1 << 15 // 32 KiB
39+
40+
type Config struct {
41+
BinDir string `toml:"bin_dir"`
42+
MaxVerifiers int `toml:"max_verifiers"`
43+
PerVerifierTimeout time.Duration `toml:"per_verifier_timeout"`
44+
}
45+
46+
type ImageVerifier struct {
47+
config *Config
48+
}
49+
50+
var _ imageverifier.ImageVerifier = (*ImageVerifier)(nil)
51+
52+
func NewImageVerifier(c *Config) *ImageVerifier {
53+
return &ImageVerifier{
54+
config: c,
55+
}
56+
}
57+
58+
func (v *ImageVerifier) VerifyImage(ctx context.Context, name string, desc ocispec.Descriptor) (*imageverifier.Judgement, error) {
59+
// os.ReadDir sorts entries by name.
60+
entries, err := os.ReadDir(v.config.BinDir)
61+
if err != nil {
62+
if errors.Is(err, os.ErrNotExist) {
63+
return &imageverifier.Judgement{
64+
OK: true,
65+
Reason: fmt.Sprintf("image verifier directory %v does not exist", v.config.BinDir),
66+
}, nil
67+
}
68+
69+
return nil, fmt.Errorf("failed to list directory contents: %w", err)
70+
}
71+
72+
if len(entries) == 0 {
73+
return &imageverifier.Judgement{
74+
OK: true,
75+
Reason: fmt.Sprintf("no image verifier binaries found in %v", v.config.BinDir),
76+
}, nil
77+
}
78+
79+
reason := &strings.Builder{}
80+
for i, entry := range entries {
81+
if (i+1) > v.config.MaxVerifiers && v.config.MaxVerifiers >= 0 {
82+
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)
83+
break
84+
}
85+
86+
bin := entry.Name()
87+
start := time.Now()
88+
exitCode, vr, err := v.runVerifier(ctx, bin, name, desc)
89+
runtime := time.Since(start)
90+
if err != nil {
91+
return nil, fmt.Errorf("failed to call verifier %v (runtime %v): %w", bin, runtime, err)
92+
}
93+
94+
if exitCode != 0 {
95+
return &imageverifier.Judgement{
96+
OK: false,
97+
Reason: fmt.Sprintf("verifier %v rejected image (exit code %v): %v", bin, exitCode, vr),
98+
}, nil
99+
}
100+
101+
if i > 0 {
102+
reason.WriteString(", ")
103+
}
104+
reason.WriteString(fmt.Sprintf("%v => %v", bin, vr))
105+
}
106+
107+
return &imageverifier.Judgement{
108+
OK: true,
109+
Reason: reason.String(),
110+
}, nil
111+
}
112+
113+
func (v *ImageVerifier) runVerifier(ctx context.Context, bin string, imageName string, desc ocispec.Descriptor) (exitCode int, reason string, err error) {
114+
ctx, cancel := context.WithTimeout(ctx, v.config.PerVerifierTimeout)
115+
defer cancel()
116+
117+
binPath := filepath.Join(v.config.BinDir, bin)
118+
args := []string{
119+
"-name", imageName,
120+
"-digest", desc.Digest.String(),
121+
"-stdin-media-type", ocispec.MediaTypeDescriptor,
122+
}
123+
124+
cmd := exec.CommandContext(ctx, binPath, args...)
125+
126+
// We construct our own pipes instead of using the default StdinPipe,
127+
// StoutPipe, and StderrPipe in order to set timeouts on reads and writes.
128+
stdinRead, stdinWrite, err := os.Pipe()
129+
if err != nil {
130+
return -1, "", err
131+
}
132+
cmd.Stdin = stdinRead
133+
defer stdinRead.Close()
134+
defer stdinWrite.Close()
135+
136+
stdoutRead, stdoutWrite, err := os.Pipe()
137+
if err != nil {
138+
return -1, "", err
139+
}
140+
cmd.Stdout = stdoutWrite
141+
defer stdoutRead.Close()
142+
defer stdoutWrite.Close()
143+
144+
stderrRead, stderrWrite, err := os.Pipe()
145+
if err != nil {
146+
return -1, "", err
147+
}
148+
cmd.Stderr = stderrWrite
149+
defer stderrRead.Close()
150+
defer stderrWrite.Close()
151+
152+
// Close parent ends of pipes on timeout. Without this, I/O may hang in the
153+
// parent process.
154+
if d, ok := ctx.Deadline(); ok {
155+
stdinWrite.SetDeadline(d)
156+
stdoutRead.SetDeadline(d)
157+
stderrRead.SetDeadline(d)
158+
}
159+
160+
// Finish configuring, and then fork & exec the child process.
161+
p, err := startProcess(ctx, cmd)
162+
if err != nil {
163+
return -1, "", err
164+
}
165+
defer p.cleanup(ctx)
166+
167+
// Close the child ends of the pipes in the parent process.
168+
stdinRead.Close()
169+
stdoutWrite.Close()
170+
stderrWrite.Close()
171+
172+
// Write the descriptor to stdin.
173+
go func() {
174+
// Descriptors are usually small enough to fit in a pipe buffer (which is
175+
// often 64 KiB on Linux) so this write usually won't block on the child
176+
// process reading stdin. However, synchronously writing to stdin may cause
177+
// the parent to block if the descriptor is larger than the pipe buffer and
178+
// the child process doesn't read stdin. Therefore, we write to stdin
179+
// asynchronously, limited by the stdinWrite deadline set above.
180+
err := json.NewEncoder(stdinWrite).Encode(desc)
181+
if err != nil {
182+
// This may error out with a "broken pipe" error if the descriptor is
183+
// larger than the pipe buffer and the child process does not read all
184+
// of stdin.
185+
log.G(ctx).WithError(err).Warn("failed to completely write descriptor to stdin")
186+
}
187+
stdinWrite.Close()
188+
}()
189+
190+
// Pipe verifier stderr lines to debug logs.
191+
stderrLog := log.G(ctx).Logger.WithFields(logrus.Fields{
192+
"image_verifier": bin,
193+
"stream": "stderr",
194+
})
195+
stderrLogDone := make(chan struct{})
196+
go func() {
197+
defer close(stderrLogDone)
198+
defer stderrRead.Close()
199+
lr := &io.LimitedReader{
200+
R: stderrRead,
201+
N: outputLimitBytes,
202+
}
203+
204+
s := bufio.NewScanner(lr)
205+
for s.Scan() {
206+
stderrLog.Debug(s.Text())
207+
}
208+
if err := s.Err(); err != nil {
209+
stderrLog.WithError(err).Debug("error logging image verifier stderr")
210+
}
211+
212+
if lr.N == 0 {
213+
// Peek ahead to see if stderr reader was truncated.
214+
b := make([]byte, 1)
215+
if n, _ := stderrRead.Read(b); n > 0 {
216+
stderrLog.Debug("(previous logs may be truncated)")
217+
}
218+
}
219+
220+
// Discard the truncated part of stderr. Doing this rather than closing the
221+
// reader avoids broken pipe errors. This is bounded by the stderrRead
222+
// deadline.
223+
if _, err := io.Copy(io.Discard, stderrRead); err != nil {
224+
log.G(ctx).WithError(err).Error("error flushing stderr")
225+
}
226+
}()
227+
228+
stdout, err := io.ReadAll(io.LimitReader(stdoutRead, outputLimitBytes))
229+
if err != nil {
230+
log.G(ctx).WithError(err).Error("error reading stdout")
231+
} else {
232+
m := strings.Builder{}
233+
m.WriteString(strings.TrimSpace(string(stdout)))
234+
// Peek ahead to see if stdout is truncated.
235+
b := make([]byte, 1)
236+
if n, _ := stdoutRead.Read(b); n > 0 {
237+
m.WriteString("(stdout truncated)")
238+
}
239+
reason = m.String()
240+
}
241+
242+
// Discard the truncated part of stdout. Doing this rather than closing the
243+
// reader avoids broken pipe errors. This is bounded by the stdoutRead
244+
// deadline.
245+
if _, err := io.Copy(io.Discard, stdoutRead); err != nil {
246+
log.G(ctx).WithError(err).Error("error flushing stdout")
247+
}
248+
stdoutRead.Close()
249+
250+
<-stderrLogDone
251+
if err := cmd.Wait(); err != nil {
252+
if ee := (&exec.ExitError{}); errors.As(err, &ee) && ee.ProcessState.Exited() {
253+
return ee.ProcessState.ExitCode(), reason, nil
254+
}
255+
return -1, "", fmt.Errorf("waiting on command to exit: %v", err)
256+
}
257+
258+
return cmd.ProcessState.ExitCode(), reason, nil
259+
}

0 commit comments

Comments
 (0)