338 lines
9.2 KiB
Go
338 lines
9.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gokrazy/autoupdate/internal/cienv"
|
|
"github.com/gokrazy/internal/config"
|
|
"github.com/gokrazy/internal/instanceflag"
|
|
"github.com/google/go-github/v35/github"
|
|
"github.com/google/renameio/v2"
|
|
)
|
|
|
|
var (
|
|
setLabel = flag.String("set_label",
|
|
"",
|
|
"if non-empty, name of a GitHub label to set on the pull request")
|
|
|
|
requireLabel = flag.String("require_label",
|
|
"",
|
|
"name of the required label before the PR will be tested")
|
|
|
|
booteryURL = flag.String("bootery_url",
|
|
"",
|
|
"/testboot URL to send boot images to")
|
|
|
|
updateRootFlag = flag.Bool("update_root",
|
|
false,
|
|
"update bakery root file system, too? required for gokrazy/kernel with loadable kernel modules")
|
|
)
|
|
|
|
func createGist(ctx context.Context, client *github.Client, log string) (string, error) {
|
|
filename := "boot-log-" + time.Now().Format(time.RFC3339)
|
|
gist, _, err := client.Gists.Create(ctx,
|
|
&github.Gist{
|
|
Description: github.String("gokrazy boot log"),
|
|
Public: github.Bool(false),
|
|
Files: map[github.GistFilename]github.GistFile{
|
|
github.GistFilename(filename): {Content: github.String(log)},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return *gist.HTMLURL, nil
|
|
}
|
|
|
|
func writeImages(hostname string) (boot string, root string, _ error) {
|
|
log.Printf("writeImages(%s)", hostname)
|
|
bootf, err := ioutil.TempFile("", "gokr-boot")
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
bootf.Close()
|
|
rootf, err := ioutil.TempFile("", "gokr-root")
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
rootf.Close()
|
|
// Inject the hostname into the instance config. The instance is
|
|
// resolved from $PWD (the CI step runs gokr-boot from within the
|
|
// instance directory, e.g. ~/gokrazy/bakery), not from the bootery
|
|
// hostname (e.g. bakeryapu2c4) — those are unrelated.
|
|
instance := instanceflag.InstanceDefault()
|
|
configJSON := (&instanceflag.Flags{
|
|
Parent: instanceflag.ParentDirDefault(),
|
|
Name: instance,
|
|
}).InstanceConfigPath()
|
|
cfg, err := config.ReadFromFile(configJSON, instance)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
cfg.ApplyEnvironment()
|
|
cfg.Hostname = hostname
|
|
b, err := cfg.FormatForFile()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := renameio.WriteFile(configJSON, b, 0644); err != nil {
|
|
return "", "", err
|
|
}
|
|
cmd := exec.Command("gok",
|
|
"overwrite",
|
|
"--boot="+bootf.Name(),
|
|
"--root="+rootf.Name())
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return bootf.Name(), rootf.Name(), cmd.Run()
|
|
}
|
|
|
|
func useBakeries(booteryURL, slug string) ([]string, error) {
|
|
u, err := url.Parse(booteryURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v := u.Query()
|
|
v.Set("slug", slug)
|
|
u.RawQuery = v.Encode()
|
|
req, err := http.NewRequest(http.MethodPut, u.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if got, want := resp.StatusCode, http.StatusOK; got != want {
|
|
b, _ := ioutil.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("unexpected HTTP status code: got %d (%s), want %d", got, strings.TrimSpace(string(b)), want)
|
|
}
|
|
b, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var useReply struct {
|
|
Hosts []string `json:"hosts"`
|
|
}
|
|
if err := json.Unmarshal(b, &useReply); err != nil {
|
|
return nil, err
|
|
}
|
|
return useReply.Hosts, nil
|
|
}
|
|
|
|
func releaseBakeries(booteryURL string) error {
|
|
req, err := http.NewRequest(http.MethodPut, booteryURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if got, want := resp.StatusCode, http.StatusOK; got != want {
|
|
b, _ := ioutil.ReadAll(resp.Body)
|
|
return fmt.Errorf("unexpected HTTP status code: got %d (%s), want %d", got, strings.TrimSpace(string(b)), want)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func streamTo(img, booteryURL, hostname, newer string) (string, error) {
|
|
f, err := os.Open(img)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
u, err := url.Parse(booteryURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
v := u.Query()
|
|
v.Set("hostname", hostname)
|
|
if newer != "" {
|
|
v.Set("boot-newer", newer)
|
|
}
|
|
u.RawQuery = v.Encode()
|
|
req, err := http.NewRequest(http.MethodPut, u.String(), f)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if got, want := resp.StatusCode, http.StatusOK; got != want {
|
|
b, _ := ioutil.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("unexpected HTTP status code: got %d (%s), want %d", got, strings.TrimSpace(string(b)), want)
|
|
}
|
|
b, err := ioutil.ReadAll(resp.Body)
|
|
return string(b), err
|
|
}
|
|
|
|
func testBoot(bootImg, booteryURL, hostname, newer string) (string, error) {
|
|
return streamTo(bootImg, booteryURL, hostname, newer)
|
|
}
|
|
|
|
func updateRoot(rootImg, booteryURL, hostname string) (string, error) {
|
|
return streamTo(rootImg, strings.TrimSuffix(booteryURL, "/testboot")+"/updateroot", hostname, "")
|
|
}
|
|
|
|
func ensureLabel(ctx context.Context, client *github.Client, owner, repo string, issueNum int, label string) error {
|
|
labels, _, err := client.Issues.ListLabelsByIssue(ctx, owner, repo, issueNum, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, l := range labels {
|
|
if *l.Name == label {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("label %q not found on issue %d", label, issueNum)
|
|
}
|
|
|
|
func addLabel(ctx context.Context, client *github.Client, owner, repo string, issueNum int, label string) error {
|
|
_, _, err := client.Issues.AddLabelsToIssue(ctx, owner, repo, issueNum, []string{label})
|
|
return err
|
|
}
|
|
|
|
func removeLabel(ctx context.Context, client *github.Client, owner, repo string, issueNum int, label string) error {
|
|
_, err := client.Issues.RemoveLabelForIssue(ctx, owner, repo, issueNum, label)
|
|
return err
|
|
}
|
|
|
|
func addComment(ctx context.Context, client *github.Client, owner, repo string, issueNum int, gistURL string) error {
|
|
_, _, err := client.Issues.CreateComment(ctx, owner, repo, issueNum, &github.IssueComment{
|
|
Body: github.String(fmt.Sprintf("Boot test successful, find the log at %s", gistURL)),
|
|
})
|
|
return err
|
|
}
|
|
|
|
func testBoot1(hostname, newer string) (string, error) {
|
|
bootImg, rootImg, err := writeImages(hostname)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.Remove(bootImg)
|
|
defer os.Remove(rootImg)
|
|
|
|
if *updateRootFlag {
|
|
log.Printf("updating root file system")
|
|
if _, err := updateRoot(rootImg, *booteryURL, hostname); err != nil {
|
|
return "", errors.New(strings.Replace(err.Error(), *booteryURL, "<bootery_url>", -1))
|
|
}
|
|
}
|
|
|
|
log.Printf("testing boot file system")
|
|
bootlog, err := testBoot(bootImg, strings.TrimSuffix(*booteryURL, "/testboot")+"/testboot1"+fmt.Sprintf("?update_root=%v", *updateRootFlag), hostname, newer)
|
|
if err != nil {
|
|
return "", errors.New(strings.Replace(err.Error(), *booteryURL, "<bootery_url>", -1))
|
|
}
|
|
return bootlog, nil
|
|
}
|
|
|
|
var (
|
|
githubUser = cienv.MustGetGithubUser()
|
|
authToken = cienv.MustGetAuthToken()
|
|
slug = cienv.MustGetSlug()
|
|
travisPullRequest = cienv.MustGetPullRequest()
|
|
)
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
|
|
if *booteryURL == "" {
|
|
log.Fatal("-bootery_url is a required flag")
|
|
}
|
|
|
|
if *requireLabel == "" {
|
|
log.Fatal("-require_label is a required flag")
|
|
}
|
|
|
|
if *setLabel == "" {
|
|
log.Fatal("-set_label is a required flag")
|
|
}
|
|
|
|
parts := strings.Split(slug, "/")
|
|
if got, want := len(parts), 2; got != want {
|
|
log.Fatalf("unexpected number of /-separated parts in %q: got %d, want %d", slug, got, want)
|
|
}
|
|
|
|
i, err := strconv.ParseInt(travisPullRequest, 0, 64)
|
|
if err != nil {
|
|
log.Fatalf("could not parse TRAVIS_PULL_REQUEST=%q as number: %v", os.Getenv("TRAVIS_PULL_REQUEST"), err)
|
|
}
|
|
issueNum := int(i)
|
|
|
|
client := github.NewClient(&http.Client{
|
|
Transport: &github.BasicAuthTransport{
|
|
Username: githubUser,
|
|
Password: authToken,
|
|
},
|
|
})
|
|
|
|
ctx := context.Background()
|
|
|
|
if err := ensureLabel(ctx, client, parts[0], parts[1], issueNum, *requireLabel); err != nil {
|
|
// Exit with exit code 0 if there is nothing to do.
|
|
log.Println(err.Error())
|
|
return
|
|
}
|
|
|
|
// Subtract a second to ensure the gokrazy build timestamp is different
|
|
// (UNIX timestamps use seconds as their granularity).
|
|
newer := strconv.FormatInt(time.Now().Unix()-1, 10)
|
|
|
|
// Power on bakeries and expand slug into hostnames
|
|
booteryBase := strings.TrimSuffix(*booteryURL, "/testboot")
|
|
hosts, err := useBakeries(booteryBase+"/usebakeries", slug)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer func() {
|
|
if err := releaseBakeries(booteryBase + "/releasebakeries"); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
log.Printf("updating hosts %q", hosts)
|
|
for _, host := range hosts {
|
|
bootlog, err := testBoot1(host, newer)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
gistURL, err := createGist(ctx, client, bootlog)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
if err := addComment(ctx, client, parts[0], parts[1], issueNum, gistURL); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if err := addLabel(ctx, client, parts[0], parts[1], issueNum, *setLabel); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
if err := removeLabel(ctx, client, parts[0], parts[1], issueNum, *requireLabel); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|