// +build integration package main import ( "bytes" "context" "encoding/json" "flag" "fmt" "io" "io/ioutil" "log" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/osbuild/osbuild-composer/internal/common" ) type testcaseStruct struct { ComposeRequest struct { Distro string Arch string Filename string } `json:"compose-request"` Manifest json.RawMessage ImageInfo json.RawMessage `json:"image-info"` Boot *struct { Type string } } // runOsbuild runs osbuild with the specified manifest and store. func runOsbuild(manifest []byte, store string) (string, error) { cmd := getOsbuildCommand(store) cmd.Stderr = os.Stderr cmd.Stdin = bytes.NewReader(manifest) var outBuffer bytes.Buffer cmd.Stdout = &outBuffer err := cmd.Run() if err != nil { if _, ok := err.(*exec.ExitError); ok { var formattedOutput bytes.Buffer _ = json.Indent(&formattedOutput, outBuffer.Bytes(), "", " ") return "", fmt.Errorf("running osbuild failed: %s", formattedOutput.String()) } return "", fmt.Errorf("running osbuild failed from an unexpected reason: %#v", err) } var result struct { OutputID string `json:"output_id"` } err = json.NewDecoder(&outBuffer).Decode(&result) if err != nil { return "", fmt.Errorf("cannot decode osbuild output: %#v", err) } return result.OutputID, nil } // extractXZ extracts an xz archive, it's just a simple wrapper around unxz(1). func extractXZ(archivePath string) error { cmd := exec.Command("unxz", archivePath) cmd.Stderr = os.Stderr cmd.Stdout = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("cannot extract xz archive: %#v", err) } return nil } // splitExtension returns a file extension as the second return value and // the rest as the first return value. // The functionality should be the same as Python splitext's func splitExtension(path string) (string, string) { ex := filepath.Ext(path) base := strings.TrimSuffix(path, ex) return base, ex } // testImageInfo runs image-info on image specified by imageImage and // compares the result with expected image info func testImageInfo(t *testing.T, imagePath string, rawImageInfoExpected []byte) { var imageInfoExpected interface{} err := json.Unmarshal(rawImageInfoExpected, &imageInfoExpected) require.NoErrorf(t, err, "cannot decode expected image info: %#v", err) cmd := exec.Command(testPaths.imageInfo, imagePath) cmd.Stderr = os.Stderr reader, writer := io.Pipe() cmd.Stdout = writer err = cmd.Start() require.NoErrorf(t, err, "image-info cannot start: %#v", err) var imageInfoGot interface{} err = json.NewDecoder(reader).Decode(&imageInfoGot) require.NoErrorf(t, err, "decoding image-info output failed: %#v", err) err = cmd.Wait() require.NoErrorf(t, err, "running image-info failed: %#v", err) assert.Equal(t, imageInfoExpected, imageInfoGot) } type timeoutError struct{} func (*timeoutError) Error() string { return "" } // trySSHOnce tries to test the running image using ssh once // It returns timeoutError if ssh command returns 255, if it runs for more // that 10 seconds or if systemd-is-running returns starting. // It returns nil if systemd-is-running returns running or degraded. // It can also return other errors in other error cases. func trySSHOnce(address string, privateKey string, ns *netNS) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() cmdName := "ssh" cmdArgs := []string{ "-p", "22", "-i", privateKey, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "redhat@" + address, "systemctl --wait is-system-running", } var cmd *exec.Cmd if ns != nil { cmd = ns.NamespacedCommandContext(ctx, cmdName, cmdArgs...) } else { cmd = exec.CommandContext(ctx, cmdName, cmdArgs...) } output, err := cmd.Output() if ctx.Err() == context.DeadlineExceeded { return &timeoutError{} } if err != nil { if exitError, ok := err.(*exec.ExitError); ok { if exitError.ExitCode() == 255 { return &timeoutError{} } } else { return fmt.Errorf("ssh command failed from unknown reason: %#v", err) } } outputString := strings.TrimSpace(string(output)) switch outputString { case "running": return nil case "degraded": log.Print("ssh test passed, but the system is degraded") return nil case "starting": return &timeoutError{} default: return fmt.Errorf("ssh test failed, system status is: %s", outputString) } } // testSSH tests the running image using ssh. // It tries 20 attempts before giving up. If a major error occurs, it might // return earlier. func testSSH(t *testing.T, address string, privateKey string, ns *netNS) { const attempts = 20 for i := 0; i < attempts; i++ { err := trySSHOnce(address, privateKey, ns) if err == nil { // pass the test return } // if any other error than the timeout one happened, fail the test immediately if _, ok := err.(*timeoutError); !ok { t.Fatal(err) } time.Sleep(10 * time.Second) } t.Errorf("ssh test failure, %d attempts were made", attempts) } func testBootUsingQemu(t *testing.T, imagePath string) { err := withNetworkNamespace(func(ns netNS) error { return withBootedQemuImage(imagePath, ns, func() error { testSSH(t, "localhost", testPaths.privateKey, &ns) return nil }) }) require.NoError(t, err) } func testBootUsingNspawnImage(t *testing.T, imagePath string, outputID string) { err := withNetworkNamespace(func(ns netNS) error { return withBootedNspawnImage(imagePath, outputID, ns, func() error { testSSH(t, "localhost", testPaths.privateKey, &ns) return nil }) }) require.NoError(t, err) } func testBootUsingNspawnDirectory(t *testing.T, imagePath string, outputID string) { err := withNetworkNamespace(func(ns netNS) error { return withExtractedTarArchive(imagePath, func(dir string) error { return withBootedNspawnDirectory(dir, outputID, ns, func() error { testSSH(t, "localhost", testPaths.privateKey, &ns) return nil }) }) }) require.NoError(t, err) } func testBootUsingAWS(t *testing.T, imagePath string) { creds, err := getAWSCredentialsFromEnv() require.NoError(t, err) // if no credentials are given, fall back to qemu if creds == nil { log.Print("no AWS credentials given, falling back to booting using qemu") testBootUsingQemu(t, imagePath) return } imageName, err := generateRandomString("osbuild-image-tests-image-") require.NoError(t, err) e, err := newEC2(creds) require.NoError(t, err) // the following line should be done by osbuild-composer at some point err = uploadImageToAWS(creds, imagePath, imageName) require.NoErrorf(t, err, "upload to amazon failed, resources could have been leaked") imageDesc, err := describeEC2Image(e, imageName) require.NoErrorf(t, err, "cannot describe the ec2 image") // delete the image after the test is over defer func() { err = deleteEC2Image(e, imageDesc) require.NoErrorf(t, err, "cannot delete the ec2 image, resources could have been leaked") }() // boot the uploaded image and try to connect to it err = withSSHKeyPair(func(privateKey, publicKey string) error { return withBootedImageInEC2(e, imageDesc, publicKey, func(address string) error { testSSH(t, address, privateKey, nil) return nil }) }) require.NoError(t, err) } // testBoot tests if the image is able to successfully boot // Before the test it boots the image respecting the specified bootType. // The test passes if the function is able to connect to the image via ssh // in defined number of attempts and systemd-is-running returns running // or degraded status. func testBoot(t *testing.T, imagePath string, bootType string, outputID string) { switch bootType { case "qemu": fallthrough case "qemu-extract": testBootUsingQemu(t, imagePath) case "nspawn": testBootUsingNspawnImage(t, imagePath, outputID) case "nspawn-extract": testBootUsingNspawnDirectory(t, imagePath, outputID) case "aws": testBootUsingAWS(t, imagePath) default: panic("unknown boot type!") } } // testImage performs a series of tests specified in the testcase // on an image func testImage(t *testing.T, testcase testcaseStruct, imagePath, outputID string) { if testcase.ImageInfo != nil { t.Run("image info", func(t *testing.T) { testImageInfo(t, imagePath, testcase.ImageInfo) }) } if testcase.Boot != nil { t.Run("boot", func(t *testing.T) { testBoot(t, imagePath, testcase.Boot.Type, outputID) }) } } // runTestcase builds the pipeline specified in the testcase and then it // tests the result func runTestcase(t *testing.T, testcase testcaseStruct) { store, err := ioutil.TempDir("/var/tmp", "osbuild-image-tests-") require.NoErrorf(t, err, "cannot create temporary store: %#v", err) defer func() { err := os.RemoveAll(store) if err != nil { log.Printf("cannot remove temporary store: %#v\n", err) } }() outputID, err := runOsbuild(testcase.Manifest, store) require.NoError(t, err) imagePath := fmt.Sprintf("%s/refs/%s/%s", store, outputID, testcase.ComposeRequest.Filename) // if the result is xz archive but not tar.xz archive, extract it base, ex := splitExtension(imagePath) if ex == ".xz" { _, ex = splitExtension(base) if ex != ".tar" { err := extractXZ(imagePath) require.NoError(t, err) imagePath = base } } testImage(t, testcase, imagePath, outputID) } // getAllCases returns paths to all testcases in the testcase directory func getAllCases() ([]string, error) { cases, err := ioutil.ReadDir(testPaths.testCasesDirectory) if err != nil { return nil, fmt.Errorf("cannot list test cases: %#v", err) } casesPaths := []string{} for _, c := range cases { if c.IsDir() { continue } casePath := fmt.Sprintf("%s/%s", testPaths.testCasesDirectory, c.Name()) casesPaths = append(casesPaths, casePath) } return casesPaths, nil } // runTests opens, parses and runs all the specified testcases func runTests(t *testing.T, cases []string) { for _, path := range cases { t.Run(path, func(t *testing.T) { f, err := os.Open(path) require.NoErrorf(t, err, "%s: cannot open test case: %#v", path, err) var testcase testcaseStruct err = json.NewDecoder(f).Decode(&testcase) require.NoErrorf(t, err, "%s: cannot decode test case: %#v", path, err) currentArch := common.CurrentArch() if testcase.ComposeRequest.Arch != currentArch { t.Skipf("the required arch is %s, the current arch is %s", testcase.ComposeRequest.Arch, currentArch) } runTestcase(t, testcase) }) } } func TestImages(t *testing.T) { cases := flag.Args() // if no cases were specified, run the default set if len(cases) == 0 { var err error cases, err = getAllCases() require.NoError(t, err) } runTests(t, cases) }