package s2i_test import ( "archive/tar" "bytes" "context" "errors" "fmt" "io" "io/ioutil" "log" "net" "net/http" "os" "path/filepath" "strings" "testing" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/registry" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/tarball" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/openshift/source-to-image/pkg/api" fn "knative.dev/kn-plugin-func" "knative.dev/kn-plugin-func/builders" "knative.dev/kn-plugin-func/s2i" . "knative.dev/kn-plugin-func/testing" ) // Test_BuilderImageDefault ensures that a function being built which does not // define a Builder Image will default. func Test_BuilderImageDefault(t *testing.T) { var ( i = &mockImpl{} // mock underlying s2i implementation c = mockDocker{} // mock docker client f = fn.Function{Runtime: "node"} // function with no builder image set b = s2i.NewBuilder( // func S2I Builder logic s2i.WithImpl(i), s2i.WithDockerClient(c)) ) // An implementation of the underlying S2I implementation which verifies // the config has arrived as expected (correct functions logic applied) i.BuildFn = func(cfg *api.Config) (*api.Result, error) { expected := s2i.DefaultBuilderImages["node"] if cfg.BuilderImage != expected { t.Fatalf("expected s2i config builder image '%v', got '%v'", expected, cfg.BuilderImage) } return nil, nil } // Invoke Build, which runs function Builder logic before invoking the // mock impl above. if err := b.Build(context.Background(), f); err != nil { t.Fatal(err) } } // Test_BuilderImageConfigurable ensures that the builder will use the builder // image defined on the given function if provided. func Test_BuilderImageConfigurable(t *testing.T) { var ( i = &mockImpl{} // mock underlying s2i implementation c = mockDocker{} // mock docker client b = s2i.NewBuilder( // func S2I Builder logic s2i.WithName(builders.S2I), s2i.WithImpl(i), s2i.WithDockerClient(c)) f = fn.Function{ // function with a builder image set Runtime: "node", BuilderImages: map[string]string{ builders.S2I: "example.com/user/builder-image", }, } ) // An implementation of the underlying S2I implementation which verifies // the config has arrived as expected (correct functions logic applied) i.BuildFn = func(cfg *api.Config) (*api.Result, error) { expected := "example.com/user/builder-image" if cfg.BuilderImage != expected { t.Fatalf("expected s2i config builder image for node to be '%v', got '%v'", expected, cfg.BuilderImage) } return nil, nil } // Invoke Build, which runs function Builder logic before invoking the // mock impl above. if err := b.Build(context.Background(), f); err != nil { t.Fatal(err) } } // Test_Verbose ensures that the verbosity flag is propagated to the // S2I builder implementation. func Test_BuilderVerbose(t *testing.T) { c := mockDocker{} // mock docker client assert := func(verbose bool) { i := &mockImpl{ BuildFn: func(cfg *api.Config) (r *api.Result, err error) { if cfg.Quiet == verbose { t.Fatalf("expected s2i quiet mode to be !%v when verbose %v", verbose, verbose) } return &api.Result{Messages: []string{"message"}}, nil }} if err := s2i.NewBuilder(s2i.WithVerbose(verbose), s2i.WithImpl(i), s2i.WithDockerClient(c)).Build(context.Background(), fn.Function{Runtime: "node"}); err != nil { t.Fatal(err) } } assert(true) // when verbose is on, quiet should remain off assert(false) // when verbose is off, quiet should be toggled on } // Test_BuildEnvs ensures that build environment variables on the function // are interpolated and passed to the S2I build implementation in the final // build config. func Test_BuildEnvs(t *testing.T) { defer WithEnvVar(t, "INTERPOLATE_ME", "interpolated")() var ( envName = "NAME" envValue = "{{ env:INTERPOLATE_ME }}" f = fn.Function{ Runtime: "node", BuildEnvs: []fn.Env{{Name: &envName, Value: &envValue}}, } i = &mockImpl{} c = mockDocker{} b = s2i.NewBuilder(s2i.WithImpl(i), s2i.WithDockerClient(c)) ) i.BuildFn = func(cfg *api.Config) (r *api.Result, err error) { for _, v := range cfg.Environment { if v.Name == envName && v.Value == "interpolated" { return // success! } else if v.Name == envName && v.Value == envValue { t.Fatal("build env was not interpolated") } } t.Fatal("build envs not added to builder impl config") return } if err := b.Build(context.Background(), f); err != nil { t.Fatal(err) } } func TestS2IScriptURL(t *testing.T) { testRegistry := startRegistry(t) // builder that is only in registry not in daemon remoteBuilder := testRegistry + "/default/builder:remote" // builder that is in daemon localBuilder := "example.com/default/builder:local" // begin push testing builder to registry tag, err := name.NewTag(remoteBuilder) if err != nil { t.Fatal(err) } img, err := tarball.ImageFromPath(filepath.Join("testData", "builder.tar"), nil) if err != nil { t.Fatal(err) } err = remote.Write(&tag, img) if err != nil { t.Fatal(err) } // end push testing builder to registry scriptURL := "image:///usr/local/s2i" cli := mockDocker{ inspect: func(ctx context.Context, image string) (types.ImageInspect, []byte, error) { if image != localBuilder { return types.ImageInspect{}, nil, notFoundErr{} } return types.ImageInspect{ Config: &container.Config{Labels: map[string]string{"io.openshift.s2i.scripts-url": scriptURL}}, }, nil, nil }, } impl := &mockImpl{ BuildFn: func(config *api.Config) (*api.Result, error) { if config.ScriptsURL != scriptURL { return nil, fmt.Errorf("unexepeted ScriptURL: %q", config.ScriptsURL) } return nil, nil }, } tests := []struct { name string builderImage string }{ {name: "builder in daemon", builderImage: localBuilder}, {name: "builder not in daemon", builderImage: remoteBuilder}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f := fn.Function{ Runtime: "node", BuilderImages: map[string]string{ builders.S2I: tt.builderImage, }, } b := s2i.NewBuilder(s2i.WithName(builders.S2I), s2i.WithImpl(impl), s2i.WithDockerClient(cli)) err = b.Build(context.Background(), f) if err != nil { t.Error(err) } }) } } func startRegistry(t *testing.T) (addr string) { s := http.Server{ Handler: registry.New(registry.Logger(log.New(io.Discard, "", 0))), } t.Cleanup(func() { s.Close() }) l, err := net.Listen("tcp", "localhost:0") if err != nil { t.Fatal(err) } addr = l.Addr().String() go func() { err = s.Serve(l) if err != nil && !errors.Is(err, net.ErrClosed) { fmt.Fprintln(os.Stderr, "ERROR: ", err) } }() return addr } func TestBuildContextUpload(t *testing.T) { dockerfileContent := []byte("FROM scratch\nLABEL A=42") atxtContent := []byte("hello world!\n") cli := mockDocker{ build: func(ctx context.Context, context io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) { tr := tar.NewReader(context) for { hdr, err := tr.Next() if err != nil { if errors.Is(err, io.EOF) { break } return types.ImageBuildResponse{}, err } switch hdr.Name { case ".": case "Dockerfile": bs, err := ioutil.ReadAll(tr) if err != nil { return types.ImageBuildResponse{}, err } if !bytes.Equal(bs, dockerfileContent) { return types.ImageBuildResponse{}, errors.New("bad content for Dockerfile") } case "a.txt": bs, err := ioutil.ReadAll(tr) if err != nil { return types.ImageBuildResponse{}, err } if !bytes.Equal(bs, atxtContent) { return types.ImageBuildResponse{}, errors.New("bad content for a.txt") } default: return types.ImageBuildResponse{}, errors.New("unexpected file") } } return types.ImageBuildResponse{ Body: io.NopCloser(strings.NewReader(`{"stream": "OK!"}`)), OSType: "linux", }, nil }, } impl := &mockImpl{ BuildFn: func(config *api.Config) (*api.Result, error) { err := ioutil.WriteFile(config.AsDockerfile, dockerfileContent, 0644) if err != nil { return nil, err } err = ioutil.WriteFile(filepath.Join(filepath.Dir(config.AsDockerfile), "a.txt"), atxtContent, 0644) if err != nil { return nil, err } return nil, nil }, } f := fn.Function{ Runtime: "node", } b := s2i.NewBuilder(s2i.WithImpl(impl), s2i.WithDockerClient(cli)) err := b.Build(context.Background(), f) if err != nil { t.Error(err) } } func TestBuildFail(t *testing.T) { cli := mockDocker{ build: func(ctx context.Context, context io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) { return types.ImageBuildResponse{ Body: io.NopCloser(strings.NewReader(`{"errorDetail": {"message": "Error: this is expected"}}`)), OSType: "linux", }, nil }, } impl := &mockImpl{ BuildFn: func(config *api.Config) (*api.Result, error) { return &api.Result{Success: true}, nil }, } b := s2i.NewBuilder(s2i.WithImpl(impl), s2i.WithDockerClient(cli)) err := b.Build(context.Background(), fn.Function{Runtime: "node"}) if err == nil || !strings.Contains(err.Error(), "Error: this is expected") { t.Error("didn't get expected error") } } // mockImpl is a mock implementation of an S2I builder. type mockImpl struct { BuildFn func(*api.Config) (*api.Result, error) } func (i *mockImpl) Build(cfg *api.Config) (*api.Result, error) { return i.BuildFn(cfg) } type mockDocker struct { inspect func(ctx context.Context, image string) (types.ImageInspect, []byte, error) build func(ctx context.Context, context io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) } func (m mockDocker) ImageInspectWithRaw(ctx context.Context, image string) (types.ImageInspect, []byte, error) { if m.inspect != nil { return m.inspect(ctx, image) } return types.ImageInspect{}, nil, nil } func (m mockDocker) ImageBuild(ctx context.Context, context io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) { if m.build != nil { return m.build(ctx, context, options) } _, _ = io.Copy(io.Discard, context) return types.ImageBuildResponse{ Body: io.NopCloser(strings.NewReader("")), OSType: "linux", }, nil } type notFoundErr struct { } func (n notFoundErr) Error() string { return "not found" } func (n notFoundErr) NotFound() bool { return true }