mirror of https://github.com/knative/func.git
563 lines
16 KiB
Go
563 lines
16 KiB
Go
package oci
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
fn "knative.dev/func/pkg/functions"
|
|
. "knative.dev/func/pkg/testing"
|
|
)
|
|
|
|
var TestPlatforms = []fn.Platform{{OS: "linux", Architecture: runtime.GOARCH}}
|
|
|
|
// TestBuilder_BuildGo ensures that, when given a Go Function, an OCI-compliant
|
|
// directory structure is created on .Build in the expected path.
|
|
func TestBuilder_BuildGo(t *testing.T) {
|
|
root, done := Mktemp(t)
|
|
defer done()
|
|
|
|
client := fn.New(fn.WithVerbose(true))
|
|
|
|
f, err := client.Init(fn.Function{Root: root, Runtime: "go"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
builder := NewBuilder("", true)
|
|
|
|
if err := builder.Build(context.Background(), f, TestPlatforms); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
last := filepath.Join(f.Root, fn.RunDataDir, "builds", "last", "oci")
|
|
|
|
validateOCIStructure(last, t) // validate OCI compliant
|
|
}
|
|
|
|
// TestBuilder_BuildPython ensures that, when given a Python Function, an
|
|
// OCI-compliant directory structure is created on .Build in the expected path.
|
|
func TestBuilder_BuildPython(t *testing.T) {
|
|
testPython, _ := strconv.ParseBool(os.Getenv("FUNC_TEST_PYTHON"))
|
|
if !testPython {
|
|
// NOTE: language-specific tests will be integrated more wholistically
|
|
// in our upcoming E2E test refactor
|
|
t.Skip("Skipping test that requires special environment setup")
|
|
}
|
|
root, done := Mktemp(t)
|
|
defer done()
|
|
|
|
client := fn.New(fn.WithVerbose(true))
|
|
|
|
f, err := client.Init(fn.Function{Root: root, Runtime: "python"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
builder := NewBuilder("", true)
|
|
|
|
if err := builder.Build(context.Background(), f, TestPlatforms); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
last := filepath.Join(f.Root, fn.RunDataDir, "builds", "last", "oci")
|
|
|
|
validateOCIStructure(last, t) // validate OCI compliant
|
|
}
|
|
|
|
// TestBuilder_Files ensures that static files are added to the container
|
|
// image as expected. This includes template files, regular files and links.
|
|
func TestBuilder_Files(t *testing.T) {
|
|
root, done := Mktemp(t)
|
|
defer done()
|
|
|
|
// Create a function with the default template
|
|
f, err := fn.New().Init(fn.Function{Root: root, Runtime: "go"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Add a regular file
|
|
if err := os.WriteFile("a.txt", []byte("file a"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Links
|
|
var link struct {
|
|
Target string
|
|
Mode fs.FileMode
|
|
Executable bool
|
|
}
|
|
if runtime.GOOS != "windows" {
|
|
// Default case: use symlinks
|
|
link.Target = "a.txt"
|
|
link.Mode = fs.ModeSymlink
|
|
link.Executable = true
|
|
|
|
if err := os.Symlink("a.txt", "a.lnk"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
} else {
|
|
// Windows: create a copy
|
|
if err := os.WriteFile("a.lnk", []byte("file a"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if err := NewBuilder("", true).Build(context.Background(), f, TestPlatforms); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
expected := []fileInfo{
|
|
{Path: "/etc/pki/tls/certs/ca-certificates.crt"},
|
|
{Path: "/etc/ssl/certs/ca-certificates.crt"},
|
|
{Path: "/func", Type: fs.ModeDir},
|
|
{Path: "/func/README.md"},
|
|
{Path: "/func/a.lnk", Linkname: link.Target, Type: link.Mode, Executable: link.Executable},
|
|
{Path: "/func/a.txt"},
|
|
{Path: "/func/f", Executable: true},
|
|
{Path: "/func/func.yaml"},
|
|
{Path: "/func/go.mod"},
|
|
{Path: "/func/handle.go"},
|
|
{Path: "/func/handle_test.go"},
|
|
}
|
|
|
|
last := filepath.Join(f.Root, fn.RunDataDir, "builds", "last", "oci")
|
|
|
|
validateOCIFiles(last, expected, t)
|
|
}
|
|
|
|
// TestBuilder_Concurrency
|
|
func TestBuilder_Concurrency(t *testing.T) {
|
|
root, done := Mktemp(t)
|
|
defer done()
|
|
|
|
client := fn.New()
|
|
|
|
// Initialize a new Go Function
|
|
f, err := client.Init(fn.Function{Root: root, Runtime: "go"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Concurrency
|
|
//
|
|
// The first builder is setup to use a mock implementation of the
|
|
// builder function which will block until released after first notifying
|
|
// that it has been paused.
|
|
//
|
|
// When the test receives the message that the builder has been paused, it
|
|
// starts a second, concurrently executing builder to ensure there is a
|
|
// typed error returned indicating a build is in progress.
|
|
//
|
|
// When the second builder completes, having confirmed the error message
|
|
// received is as expected. It signals the first (blocked) builder that it
|
|
// can now continue.
|
|
|
|
// Thet test waits until the first builder notifies that it is done, and
|
|
// has therefore ran its tests as well.
|
|
|
|
var (
|
|
pausedCh = make(chan bool)
|
|
continueCh = make(chan bool)
|
|
wg sync.WaitGroup
|
|
)
|
|
|
|
// Build A
|
|
builder1 := NewBuilder("builder1", true)
|
|
testImplA := NewTestLanguageBuilder()
|
|
testImplA.WritePlatformFn = func(job buildJob, p v1.Platform) ([]imageLayer, error) {
|
|
if isFirstBuild(job, p) {
|
|
pausedCh <- true // Notify of being paused
|
|
<-continueCh // Block until released
|
|
}
|
|
return []imageLayer{}, nil
|
|
}
|
|
builder1.impl = testImplA
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if err := builder1.Build(context.Background(), f, TestPlatforms); err != nil {
|
|
t.Errorf("test build error: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Wait until build 1 indicates it is paused
|
|
<-pausedCh
|
|
|
|
// Build B
|
|
builder2 := NewBuilder("builder2", true)
|
|
testImplB := NewTestLanguageBuilder()
|
|
testImplB.WritePlatformFn = func(job buildJob, p v1.Platform) ([]imageLayer, error) {
|
|
return []imageLayer{}, fmt.Errorf("the buildFn should not have been invoked")
|
|
}
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
err = builder2.Build(context.Background(), f, TestPlatforms)
|
|
if !errors.As(err, &ErrBuildInProgress{}) {
|
|
t.Errorf("test build error: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Release the blocking Build A and wait until complete.
|
|
continueCh <- true
|
|
wg.Wait()
|
|
}
|
|
|
|
func isFirstBuild(cfg buildJob, current v1.Platform) bool {
|
|
first := cfg.platforms[0]
|
|
return current.OS == first.OS &&
|
|
current.Architecture == first.Architecture &&
|
|
current.Variant == first.Variant
|
|
}
|
|
|
|
// ImageIndex represents the structure of an OCI Image Index.
|
|
type ImageIndex struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Manifests []struct {
|
|
MediaType string `json:"mediaType"`
|
|
Size int64 `json:"size"`
|
|
Digest string `json:"digest"`
|
|
Platform struct {
|
|
Architecture string `json:"architecture"`
|
|
OS string `json:"os"`
|
|
} `json:"platform"`
|
|
} `json:"manifests"`
|
|
}
|
|
|
|
// validateOCIStructue performs a cursory check that the given path exists and
|
|
// has the basics of an OCI compliant structure.
|
|
func validateOCIStructure(path string, t *testing.T) {
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("unable to stat output path. %v", path)
|
|
return
|
|
}
|
|
|
|
ociLayoutFile := filepath.Join(path, "oci-layout")
|
|
indexJSONFile := filepath.Join(path, "index.json")
|
|
blobsDir := filepath.Join(path, "blobs")
|
|
|
|
// Check if required files and directories exist
|
|
if _, err := os.Stat(ociLayoutFile); os.IsNotExist(err) {
|
|
t.Fatal("missing oci-layout file")
|
|
}
|
|
if _, err := os.Stat(indexJSONFile); os.IsNotExist(err) {
|
|
t.Fatal("missing index.json file")
|
|
}
|
|
if _, err := os.Stat(blobsDir); os.IsNotExist(err) {
|
|
t.Fatal("missing blobs directory")
|
|
}
|
|
|
|
// Load and validate index.json
|
|
indexJSONData, err := os.ReadFile(indexJSONFile)
|
|
if err != nil {
|
|
t.Fatalf("failed to read index.json: %v", err)
|
|
}
|
|
|
|
var imageIndex ImageIndex
|
|
err = json.Unmarshal(indexJSONData, &imageIndex)
|
|
if err != nil {
|
|
t.Fatalf("failed to parse index.json: %v", err)
|
|
}
|
|
|
|
if imageIndex.SchemaVersion != 2 {
|
|
t.Fatalf("invalid schema version, expected 2, got %d", imageIndex.SchemaVersion)
|
|
}
|
|
|
|
if len(imageIndex.Manifests) < 1 {
|
|
t.Fatal("no manifests")
|
|
}
|
|
}
|
|
|
|
// validateOCIFiles ensures that the OCI image at path contains files with
|
|
// the given attributes.
|
|
func validateOCIFiles(path string, expected []fileInfo, t *testing.T) {
|
|
// Load the Image Index
|
|
bb, err := os.ReadFile(filepath.Join(path, "index.json"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read index.json: %v", err)
|
|
}
|
|
var imageIndex ImageIndex
|
|
if err = json.Unmarshal(bb, &imageIndex); err != nil {
|
|
t.Fatalf("failed to parse index.json: %v", err)
|
|
}
|
|
|
|
// Load the first manifest
|
|
digest := strings.TrimPrefix(imageIndex.Manifests[0].Digest, "sha256:")
|
|
manifestFile := filepath.Join(path, "blobs", "sha256", digest)
|
|
manifestFileData, err := os.ReadFile(manifestFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mf := struct {
|
|
Layers []struct {
|
|
Digest string `json:"digest"`
|
|
} `json:"layers"`
|
|
}{}
|
|
err = json.Unmarshal(manifestFileData, &mf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var files []fileInfo
|
|
|
|
for _, layer := range mf.Layers {
|
|
func() {
|
|
digest = strings.TrimPrefix(layer.Digest, "sha256:")
|
|
f, err := os.Open(filepath.Join(path, "blobs", "sha256", digest))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
gr, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer gr.Close()
|
|
|
|
tr := tar.NewReader(gr)
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
t.Fatal(err)
|
|
}
|
|
files = append(files, fileInfo{
|
|
Path: hdr.Name,
|
|
Type: hdr.FileInfo().Mode() & fs.ModeType,
|
|
Executable: (hdr.FileInfo().Mode()&0111 == 0111) && !hdr.FileInfo().IsDir(),
|
|
Linkname: hdr.Linkname,
|
|
})
|
|
}
|
|
}()
|
|
}
|
|
sort.Slice(files, func(i, j int) bool {
|
|
return files[i].Path < files[j].Path
|
|
})
|
|
|
|
if diff := cmp.Diff(expected, files); diff != "" {
|
|
t.Error("files in oci differ from expectation (-want, +got):", diff)
|
|
}
|
|
}
|
|
|
|
type fileInfo struct {
|
|
Path string
|
|
Type fs.FileMode
|
|
Executable bool
|
|
Linkname string
|
|
}
|
|
|
|
// TestBuilder_StaticEnvs ensures that certain "static" environment variables
|
|
// comprising Function metadata are added to the config.
|
|
func TestBuilder_StaticEnvs(t *testing.T) {
|
|
root, done := Mktemp(t)
|
|
defer done()
|
|
|
|
staticEnvs := []string{
|
|
"FUNC_CREATED",
|
|
"FUNC_VERSION",
|
|
}
|
|
|
|
f, err := fn.New().Init(fn.Function{Root: root, Runtime: "go"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := NewBuilder("", true).Build(context.Background(), f, TestPlatforms); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Assert
|
|
// Check if the OCI container defines at least one of the static
|
|
// variables on each of the constituent containers.
|
|
// ---
|
|
// Get the images list (manifest descripors) from the index
|
|
ociPath := filepath.Join(f.Root, fn.RunDataDir, "builds", "last", "oci")
|
|
data, err := os.ReadFile(filepath.Join(ociPath, "index.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var index struct {
|
|
Manifests []struct {
|
|
Digest string `json:"digest"`
|
|
} `json:"manifests"`
|
|
}
|
|
if err := json.Unmarshal(data, &index); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, manifestDesc := range index.Manifests {
|
|
|
|
// Dereference the manifest descriptor into the referenced image manifest
|
|
manifestHash := strings.TrimPrefix(manifestDesc.Digest, "sha256:")
|
|
data, err := os.ReadFile(filepath.Join(ociPath, "blobs", "sha256", manifestHash))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var manifest struct {
|
|
Config struct {
|
|
Digest string `json:"digest"`
|
|
} `json:"config"`
|
|
}
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// From the image manifest get the image's config.json
|
|
configHash := strings.TrimPrefix(manifest.Config.Digest, "sha256:")
|
|
data, err = os.ReadFile(filepath.Join(ociPath, "blobs", "sha256", configHash))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var config struct {
|
|
Config struct {
|
|
Env []string `json:"Env"`
|
|
} `json:"config"`
|
|
}
|
|
if err := json.Unmarshal(data, &config); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
containsEnv := func(ss []string, name string) bool {
|
|
for _, s := range ss {
|
|
if strings.HasPrefix(s, name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
for _, expected := range staticEnvs {
|
|
t.Logf("checking for %q in slice %v", expected, config.Config.Env)
|
|
if containsEnv(config.Config.Env, expected) {
|
|
continue // to check the rest
|
|
}
|
|
t.Fatalf("static env %q not found in resultant container", expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ----------- Mock Language Builder Impl ------
|
|
|
|
// TestLanguageBuilder is the language-specific builder implementation used by the
|
|
// OCI builder for each language, and can be overridden for testing
|
|
type TestLanguageBuilder struct {
|
|
BaseInvoked bool
|
|
BaseFn func() string
|
|
|
|
WriteSharedInvoked bool
|
|
WriteSharedFn func(buildJob) ([]imageLayer, error)
|
|
|
|
WritePlatformInvoked bool
|
|
WritePlatformFn func(buildJob, v1.Platform) ([]imageLayer, error)
|
|
|
|
ConfigureInvoked bool
|
|
ConfigureFn func(buildJob, v1.Platform, v1.ConfigFile) (v1.ConfigFile, error)
|
|
}
|
|
|
|
func NewTestLanguageBuilder() *TestLanguageBuilder {
|
|
return &TestLanguageBuilder{
|
|
BaseFn: func() string { return "" },
|
|
WriteSharedFn: func(buildJob) ([]imageLayer, error) { return []imageLayer{}, nil },
|
|
WritePlatformFn: func(buildJob, v1.Platform) ([]imageLayer, error) { return []imageLayer{}, nil },
|
|
ConfigureFn: func(buildJob, v1.Platform, v1.ConfigFile) (v1.ConfigFile, error) {
|
|
return v1.ConfigFile{}, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func (l *TestLanguageBuilder) Base() string {
|
|
l.BaseInvoked = true
|
|
return l.BaseFn()
|
|
}
|
|
|
|
func (l *TestLanguageBuilder) WriteShared(job buildJob) ([]imageLayer, error) {
|
|
l.WriteSharedInvoked = true
|
|
return l.WriteSharedFn(job)
|
|
}
|
|
|
|
func (l *TestLanguageBuilder) WritePlatform(job buildJob, p v1.Platform) ([]imageLayer, error) {
|
|
l.WritePlatformInvoked = true
|
|
return l.WritePlatformFn(job, p)
|
|
}
|
|
|
|
func (l *TestLanguageBuilder) Configure(job buildJob, p v1.Platform, c v1.ConfigFile) (v1.ConfigFile, error) {
|
|
l.ConfigureInvoked = true
|
|
return l.ConfigureFn(job, p, c)
|
|
}
|
|
|
|
// Test_validatedLinkTaarget ensures that the function disallows
|
|
// links which are absolute or refer to targets outside the given root, in
|
|
// addition to the basic job of returning the value of reading the link.
|
|
func Test_validatedLinkTarget(t *testing.T) {
|
|
root := filepath.Join("testdata", "test-links")
|
|
|
|
err := os.Symlink("/var/example/absolute/link", filepath.Join(root, "absoluteLink"))
|
|
if err != nil && !errors.Is(err, os.ErrExist) {
|
|
t.Fatal(err)
|
|
}
|
|
err = os.Symlink("c://some/absolute/path", filepath.Join(root, "absoluteLinkWindows"))
|
|
if err != nil && !errors.Is(err, os.ErrExist) {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Windows-specific absolute link and link target values:
|
|
absoluteLink := "absoluteLink"
|
|
linkTarget := "./a.txt"
|
|
if runtime.GOOS == "windows" {
|
|
absoluteLink = "absoluteLinkWindows"
|
|
linkTarget = ".\\a.txt"
|
|
}
|
|
|
|
tests := []struct {
|
|
path string // path of the file within test project root
|
|
valid bool // If it should be considered valid
|
|
target string // optional test of the returned value (target)
|
|
name string // descriptive name of the test
|
|
}{
|
|
{absoluteLink, false, "", "disallow absolute-path links on linux"},
|
|
{"a.lnk", true, linkTarget, "spot-check link target"},
|
|
{"a.lnk", true, "", "links to files within the root are allowed"},
|
|
{"...validName.lnk", true, "", "allow links with target of dot prefixed names"},
|
|
{"linkToRoot", true, "", "allow links to the project root"},
|
|
{"b/linkToRoot", true, "", "allow links to the project root from within subdir"},
|
|
{"b/linkToCurrentDir", true, "", "allow links to a subdirectory within the project"},
|
|
{"b/linkToRootsParent", false, "", "disallow links to the project's immediate parent"},
|
|
{"b/linkOutsideRootsParent", false, "", "disallow links outside project root and its parent"},
|
|
{"b/c/linkToParent", true, "", " allow links up, but within project"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
path := filepath.Join(root, tt.path)
|
|
target, err := validatedLinkTarget(root, path)
|
|
|
|
if err == nil != tt.valid {
|
|
t.Fatalf("expected validity '%v', got '%v'", tt.valid, err)
|
|
}
|
|
if tt.target != "" && target != tt.target {
|
|
t.Fatalf("expected target %q, got %q", tt.target, target)
|
|
}
|
|
})
|
|
}
|
|
|
|
}
|