commit a15ff9ba5f51f695e1f7a5d0a3f9b831b79c8b90 Author: kisekinopureya Date: Thu Sep 25 10:45:27 2025 +0300 test release diff --git a/cmd/extractor/main.go b/cmd/extractor/main.go new file mode 100644 index 0000000..3788b5e --- /dev/null +++ b/cmd/extractor/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + + "kisekinopureya.com.tr/updater/internal/archive" +) + +func sha256sum(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func main() { + if len(os.Args) != 3 { + fmt.Printf("Usage: %s \n", os.Args[0]) + os.Exit(1) + } + + archiveFile := os.Args[1] + sig := os.Args[2] + + // Step 1: verify GPG signature + cmd := exec.Command("/usr/bin/gpgv", "--keyring", "/etc/updates-public.gpg", sig, archiveFile) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "GPG verification failed: %v\n", err) + os.Exit(1) + } + fmt.Println("GPG signature verified.") + + // Step 2: open archiveFile + f, err := os.Open(archiveFile) + if err != nil { + panic(err) + } + defer f.Close() + + // Step 2: extract tar.xz using system tar + cmdTar := exec.Command("tar", "-xJf", archiveFile) + cmdTar.Stdout = os.Stdout + cmdTar.Stderr = os.Stderr + if err := cmdTar.Run(); err != nil { + panic(err) + } + + metaFile := "metadata.json" + f, err = os.Open(metaFile) + if err != nil { + panic(err) + } + defer f.Close() + + var meta archive.Output + if err := json.NewDecoder(f).Decode(&meta); err != nil { + panic(err) + } + + // Step 4: check sha256 + ok := true + for _, file := range meta.Files { + sum, err := sha256sum(file.Name) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to hash %s: %v\n", file.Name, err) + ok = false + continue + } + if sum != file.Sha256 { + fmt.Fprintf(os.Stderr, "Checksum mismatch for %s!\nExpected: %s\nGot: %s\n", + file.Name, file.Sha256, sum) + ok = false + } else { + fmt.Printf("%s checksum OK\n", file.Name) + } + } + + if !ok { + os.Exit(1) + } + + fmt.Println("All files verified successfully.") +} diff --git a/cmd/os-branch-select/main.go b/cmd/os-branch-select/main.go new file mode 100644 index 0000000..0d69673 --- /dev/null +++ b/cmd/os-branch-select/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "log" + "os" + + "kisekinopureya.com.tr/updater/internal/logger" + "kisekinopureya.com.tr/updater/internal/version" +) + +func main() { + logger.InitializeLogger("/tmp/os-select-branchlog") + + log.Printf("%+v", os.Args) + + if len(os.Args) != 2 { + fmt.Println("Usage: steamos-select-branch <-stable|unstable|testing>") + os.Exit(1) + } + branchPath := version.BranchPath + + switch os.Args[1] { + case "-c": + branch := version.GetCurrentBranch() + fmt.Printf("%s\n", branch) + os.Exit(0) + case "-l": + fmt.Printf("%s\n%s\n%s\n", "stable", "unstable", "testing") + os.Exit(0) + case "stable", "unstable", "testing": + err := os.WriteFile(branchPath, []byte(os.Args[1]), 0644) + if err != nil { + log.Panic(err) + } + os.Exit(0) + default: + os.Exit(1) + } +} diff --git a/cmd/packer/main.go b/cmd/packer/main.go new file mode 100644 index 0000000..d182dd6 --- /dev/null +++ b/cmd/packer/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + "os" + + "kisekinopureya.com.tr/updater/internal/archive" +) + +func main() { + if len(os.Args) != 5 { + fmt.Fprintf(os.Stderr, "usage: %s \n", os.Args[0]) + os.Exit(1) + } + + rootfs := os.Args[1] + verity := os.Args[2] + kernel := os.Args[3] + output := os.Args[4] + + if err := archive.Pack(rootfs, verity, kernel, output); err != nil { + fmt.Fprintf(os.Stderr, "packer error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/patcher/main.go b/cmd/patcher/main.go new file mode 100644 index 0000000..0e6ce0c --- /dev/null +++ b/cmd/patcher/main.go @@ -0,0 +1,165 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + + "kisekinopureya.com.tr/updater/internal/archive" + "kisekinopureya.com.tr/updater/internal/logger" + mountfilesystem "kisekinopureya.com.tr/updater/internal/mountFilesystem" + "kisekinopureya.com.tr/updater/internal/plymouth" +) + +type BlockDevice struct { + Name string `json:"name"` + Type string `json:"type"` + PartType string `json:"parttype"` + Children []BlockDevice `json:"children,omitempty"` +} + +type LSBLK struct { + BlockDevices []BlockDevice `json:"blockdevices"` +} + +type partitionScheme struct { + EfiDevice string + RootfsDevice string + DmVerityDevice string + //VarDevice string +} + +var efiUUID string = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b" +var rootfsUUID string = "4f68bce3-e8cd-4db1-96e7-fbcaf984b709" +var dmverityUUID string = "2c7357ed-ebd2-46d9-aec1-23d437ec2bf5" + +//var varUUID string = "4d21b016-b534-45c2-a9fb-5c16e091fd2d" + +func findByPartType(devices []BlockDevice, target string) string { + for _, dev := range devices { + if dev.PartType != "" && dev.PartType == target { + return dev.Name + } + if len(dev.Children) > 0 { + if found := findByPartType(dev.Children, target); found != "" { + return found + } + } + } + return "" +} + +func populatePartitionScheme() partitionScheme { + + var generatedScheme partitionScheme = partitionScheme{} + cmd := exec.Command("lsblk", "-J", "-o", "NAME,TYPE,PARTTYPE") + blockDeviceTypes, err := cmd.Output() + + if err != nil { + log.Panic(err) + } + + var lsblk LSBLK + if err := json.Unmarshal(blockDeviceTypes, &lsblk); err != nil { + log.Panic(err) + } + generatedScheme.EfiDevice = findByPartType(lsblk.BlockDevices, efiUUID) + generatedScheme.RootfsDevice = findByPartType(lsblk.BlockDevices, rootfsUUID) + generatedScheme.DmVerityDevice = findByPartType(lsblk.BlockDevices, dmverityUUID) + + return generatedScheme +} + +func patchImage(imageFile string, partition string, name string) { + cmd := exec.Command("dd", "if="+imageFile, "of="+"/dev/"+partition, "bs=4M") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + plymouth.ShowPlymouthMessage("Patching " + name + " partition") + if err := cmd.Run(); err != nil { + log.Panic(err) + } +} + +func CopyFile(source, destDir string) (string, error) { + if err := os.MkdirAll(destDir, 0755); err != nil { + return "", fmt.Errorf("mkdir failed: %w", err) + } + + in, err := os.Open(source) + if err != nil { + return "", fmt.Errorf("open src failed: %w", err) + } + defer in.Close() + + dstPath := filepath.Join(destDir, filepath.Base(source)) + + out, err := os.Create(dstPath) + if err != nil { + return "", fmt.Errorf("create dst failed: %w", err) + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return "", fmt.Errorf("copy failed: %w", err) + } + + if err := out.Sync(); err != nil { + return "", fmt.Errorf("sync failed: %w", err) + } + + return dstPath, nil +} + +func main() { + logger.InitializeLogger("/mnt/var/log/patcher") + + if len(os.Args) != 2 { + fmt.Printf("Usage: %s \n", os.Args[0]) + os.Exit(1) + } + + f, err := os.Open(os.Args[1]) + if err != nil { + panic(err) + } + defer f.Close() + + var meta archive.Output + if err := json.NewDecoder(f).Decode(&meta); err != nil { + panic(err) + } + + currentPartitionScheme := populatePartitionScheme() + + fileCount := len(meta.Files) + + // before 20 and after 80 will be filled by initramfs + leftProgress := 60/fileCount + 20 + + for _, f := range meta.Files { + plymouth.ShowPlymouthProgress(leftProgress) + leftProgress += leftProgress + + imageFile := f.Name + imageType := f.Type + + switch imageType { + case "rootfs": + patchImage(imageFile, currentPartitionScheme.RootfsDevice, imageType) + case "dm-verity": + patchImage(imageFile, currentPartitionScheme.DmVerityDevice, imageType) + case "kernel": + mountPath := "/updates-tmp/efi" + kernelDir := "/EFI/Linux" + mountfilesystem.MountFileSystem("/dev/"+currentPartitionScheme.EfiDevice, mountPath, "vfat", 0, "") + CopyFile(imageFile, mountPath+kernelDir) + mountfilesystem.UnmountFileSystem(mountPath) + default: + } + } +} diff --git a/cmd/steamos-update/main.go b/cmd/steamos-update/main.go new file mode 100644 index 0000000..7292da2 --- /dev/null +++ b/cmd/steamos-update/main.go @@ -0,0 +1,125 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + + "kisekinopureya.com.tr/updater/internal/downloader" + "kisekinopureya.com.tr/updater/internal/logger" + "kisekinopureya.com.tr/updater/internal/version" +) + +/* + The Steam client is known to call this program with the following parameter combinations: + steamos-update --supports-duplicate-detection -- should do nothing + steamos-update --enable-duplicate-detection check -- should check for update + steamos-update check -- should check for update + steamos-update --enable-duplicate-detection -- should perform an update + steamos-update -- should perform an update +*/ + +func main() { + logger.InitializeLogger("/tmp/steamos-updatelog") + + log.Printf("%+v", os.Args) + args := os.Args[1:] + + action := performUpdate + + switch { + case len(args) == 1 && args[0] == "--supports-duplicate-detection": + os.Exit(7) + + case len(args) >= 1 && args[0] == "--enable-duplicate-detection": + if len(args) >= 2 && args[1] == "check" { + action = checkForUpdate + } + + case len(args) >= 1 && args[0] == "check": + action = checkForUpdate + } + + action() + + os.Exit(7) +} + +func fetchIndex(url string) ([]string, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch index: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad status: %s", resp.Status) + } + + var lines []string + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" { + lines = append(lines, line) + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading response failed: %w", err) + } + + return lines, nil +} + +func checkForUpdate() { + branch := version.GetCurrentBranch() + currentVersion := version.DetermineCurrentVersion() + currentVersionInt, err := strconv.ParseInt(currentVersion, 10, 32) + if err != nil { + currentVersionInt = 0 // + } + + indexFile, err := fetchIndex("http://10.26.1.3/updates/index") + if err != nil { + log.Panic(err) + } + for _, s := range indexFile { + if strings.HasPrefix(s, string(branch)) && !strings.HasSuffix(s, ".sig") { + parts := strings.Split(s, "/") + base := parts[len(parts)-1] + + // Remove extension + base = strings.TrimSuffix(base, ".tar.xz") + + // Split by dash + tokens := strings.Split(base, "-") + versionFromFile := tokens[len(tokens)-1] + versionFromFileInt, err := strconv.ParseInt(versionFromFile, 10, 32) + if err != nil { + log.Panic(err) + } + if currentVersionInt < versionFromFileInt { + fmt.Printf("Update available: %s\n", base) + os.Exit(0) + } + } + } +} + +func performUpdate() { + branch := version.GetCurrentBranch() + err := downloader.DownloadFile("http://10.26.1.3/updates/"+branch+"/latest.tar.xz.sig", "/var/cache/updates/update.tar.xz.sig", true) + if err != nil { + log.Panic(err) + } + err = downloader.DownloadFile("http://10.26.1.3/updates/"+branch+"/latest.tar.xz", "/var/cache/updates/update.tar.xz", false) + if err != nil { + log.Panic(err) + } + os.Exit(0) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4b72952 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module kisekinopureya.com.tr/updater + +go 1.24.6 + +require golang.org/x/sys v0.36.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..44f2d40 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/archive/packer.go b/internal/archive/packer.go new file mode 100644 index 0000000..8f48c89 --- /dev/null +++ b/internal/archive/packer.go @@ -0,0 +1,207 @@ +package archive + +import ( + "archive/tar" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "time" + + "kisekinopureya.com.tr/updater/internal/version" +) + +// Output metadata structure +type Output struct { + Metadata struct { + Date int64 `json:"date"` + Version string `json:"version"` + } `json:"metadata"` + Files []struct { + Name string `json:"name"` + Type string `json:"type"` + Sha256 string `json:"sha256"` + } `json:"files"` +} + +type fileInfo struct { + Name string + Type string + Path string + Sha256 string +} + +// Pack creates a tar.xz archive with metadata.json and the given images +func Pack(rootfsPath, verityPath, kernelPath, outPath string) error { + paths := []struct { + Path string + Type string + }{ + {rootfsPath, "rootfs"}, + {verityPath, "dm-verity"}, + {kernelPath, "kernel"}, + } + + var infos []fileInfo + for _, p := range paths { + log.Printf("Hashing %s...", p.Path) + sum, err := sha256File(p.Path) + if err != nil { + return fmt.Errorf("hashing %s: %w", p.Path, err) + } + infos = append(infos, fileInfo{ + Name: filepath.Base(p.Path), + Type: p.Type, + Path: p.Path, + Sha256: sum, + }) + } + + output, err := exec.Command("blkid", rootfsPath).Output() + if err != nil { + log.Fatal(err) + } + + versionFromImage, err := version.Parse(string(output)) + if err != nil { + log.Fatal() + } + + var out Output + out.Metadata.Date = time.Now().Unix() + // NOTE: version should be filled by caller if needed + out.Metadata.Version = versionFromImage + for _, fi := range infos { + out.Files = append(out.Files, struct { + Name string `json:"name"` + Type string `json:"type"` + Sha256 string `json:"sha256"` + }{ + Name: fi.Name, + Type: fi.Type, + Sha256: fi.Sha256, + }) + } + + metaBytes, err := json.MarshalIndent(out, "", " ") + if err != nil { + return fmt.Errorf("marshal metadata: %w", err) + } + + log.Printf("Metadata: %+v\n", out) + + if err := writeTarXz(outPath, metaBytes, infos); err != nil { + return fmt.Errorf("create tar.xz: %w", err) + } + + sigFile := outPath + ".sig" + cmd := exec.Command("gpg", "--output", sigFile, "--detach-sign", "--armor", outPath) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + panic(fmt.Errorf("gpg signing failed: %w", err)) + } + + fmt.Printf("Created %s and signed as %s\n", outPath, sigFile) + + return nil +} + +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func writeTarXz(outPath string, metadata []byte, files []fileInfo) error { + outFile, err := os.Create(outPath) + if err != nil { + return fmt.Errorf("create output: %w", err) + } + defer outFile.Close() + + cmd := exec.Command("xz", "-z", "-c") + cmd.Stdout = outFile + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + + if err := cmd.Start(); err != nil { + return err + } + + tw := tar.NewWriter(stdin) + + // metadata.json + metaHeader := &tar.Header{ + Name: "metadata.json", + Mode: 0644, + Size: int64(len(metadata)), + } + if err := tw.WriteHeader(metaHeader); err != nil { + return err + } + if _, err := tw.Write(metadata); err != nil { + return err + } + + // files + for _, fi := range files { + if err := addFileToTar(tw, fi.Path, fi.Name); err != nil { + return err + } + } + + if err := tw.Close(); err != nil { + return err + } + if err := stdin.Close(); err != nil { + return err + } + if err := cmd.Wait(); err != nil { + return err + } + + return nil +} + +func addFileToTar(tw *tar.Writer, srcPath, destName string) error { + f, err := os.Open(srcPath) + if err != nil { + return err + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return err + } + + hdr := &tar.Header{ + Name: destName, + Mode: int64(stat.Mode().Perm()), + Size: stat.Size(), + ModTime: stat.ModTime(), + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + + _, err = io.Copy(tw, f) + return err +} diff --git a/internal/downloader/downloader.go b/internal/downloader/downloader.go new file mode 100644 index 0000000..552f066 --- /dev/null +++ b/internal/downloader/downloader.go @@ -0,0 +1,81 @@ +package downloader + +import ( + "fmt" + "io" + "net/http" + "os" + "strconv" +) + +func DownloadFile(url, filepath string, silent bool) error { + var start int64 + if info, err := os.Stat(filepath); err == nil { + start = info.Size() + } + + out, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer out.Close() + + if start > 0 { + if _, err := out.Seek(start, 0); err != nil { + return err + } + } + + // HTTP request with Range header for resume + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + if start > 0 { + req.Header.Set("Range", "bytes="+strconv.FormatInt(start, 10)+"-") + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + os.Exit(1) + } + + // Get total size for progress calculation + var total int64 + if resp.StatusCode == http.StatusPartialContent { + contentRange := resp.Header.Get("Content-Range") // e.g., bytes 1234-9999/10000 + var sizeStr string + fmt.Sscanf(contentRange, "bytes %*d-%*d/%s", &sizeStr) + total, _ = strconv.ParseInt(sizeStr, 10, 64) + } else if resp.ContentLength > 0 { + total = start + resp.ContentLength + } + + buf := make([]byte, 32*1024) + var downloaded = start + for { + n, err := resp.Body.Read(buf) + if n > 0 { + if _, writeErr := out.Write(buf[:n]); writeErr != nil { + return writeErr + } + downloaded += int64(n) + if total > 0 && !silent { + fmt.Printf("\r%.2f%%", float64(downloaded)*100/float64(total)) + } + } + if err != nil { + if err == io.EOF { + break + } + return err + } + } + return nil +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..66bfa0a --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,23 @@ +package logger + +import ( + "log" + "os" + "sync" +) + +var once sync.Once + +func InitializeLogger(filePath string) error { + var err error + once.Do(func() { + logFile, e := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if e != nil { + err = e + return + } + log.SetOutput(logFile) + log.SetFlags(log.LstdFlags | log.Lshortfile) + }) + return err +} diff --git a/internal/mountFilesystem/mountfilesystem.go b/internal/mountFilesystem/mountfilesystem.go new file mode 100644 index 0000000..9479cf2 --- /dev/null +++ b/internal/mountFilesystem/mountfilesystem.go @@ -0,0 +1,34 @@ +package mountfilesystem + +import ( + "fmt" + "log" + "os" + + "golang.org/x/sys/unix" + "kisekinopureya.com.tr/updater/internal/logger" +) + +func MountFileSystem(source string, target string, fsType string, flags int, data string) { + logger.InitializeLogger("/tmp/mountLog") + + if err := unix.Mkdir(target, 0755); err != nil && !os.IsExist(err) { + log.Panic(err) + } + + err := unix.Mount(source, target, fsType, uintptr(flags), data) + if err != nil { + log.Panic(fmt.Errorf("mount failed: %w", err)) + } + fmt.Println("Mounted successfully at", target) +} + +func UnmountFileSystem(target string) { + logger.InitializeLogger("/tmp/unmountLog") + err := unix.Unmount(target, 0) + if err != nil { + log.Panic(err) + } + fmt.Println("Unmounted successfully at", target) + +} diff --git a/internal/plymouth/plymouth.go b/internal/plymouth/plymouth.go new file mode 100644 index 0000000..d0ed179 --- /dev/null +++ b/internal/plymouth/plymouth.go @@ -0,0 +1,24 @@ +package plymouth + +import ( + "fmt" + "os/exec" + "strconv" +) + +func ShowPlymouthMessage(message string) { + cmd := exec.Command("plymouth", "update", "--status=\""+message+"\"") + if err := cmd.Run(); err != nil { + fmt.Println(message) + } +} + +func ShowPlymouthProgress(progress int) { + if progress < 100 && progress > 0 { + progressStr := strconv.Itoa(progress) + cmd := exec.Command("plymouth", "system-update", "--progress="+progressStr+"\"") + if err := cmd.Run(); err != nil { + fmt.Printf("Progress: %s%%", progressStr) + } + } +} diff --git a/internal/version/currentVersion.go b/internal/version/currentVersion.go new file mode 100644 index 0000000..b5643c1 --- /dev/null +++ b/internal/version/currentVersion.go @@ -0,0 +1,41 @@ +package version + +import ( + "bufio" + "os" + "strings" +) + +func GetCurrentBranch() string { + branch, err := os.ReadFile(BranchPath) + if err != nil { + return "stable" + } + return string(branch) +} + +func DetermineCurrentVersion() string { + file, err := os.Open("/etc/os-release") + if err != nil { + panic(err) + } + defer file.Close() + + var lastLine string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lastLine = scanner.Text() + } + if err := scanner.Err(); err != nil { + panic(err) + } + + parts := strings.SplitN(lastLine, "=", 2) + if len(parts) != 2 { + panic("invalid format") + } + value := parts[1] + + currentVersion := strings.Trim(value, "\"") + return currentVersion +} diff --git a/internal/version/parse.go b/internal/version/parse.go new file mode 100644 index 0000000..8355528 --- /dev/null +++ b/internal/version/parse.go @@ -0,0 +1,18 @@ +package version + +import ( + "fmt" + "regexp" +) + +var BranchPath = "/var/lib/os-branch" + +// Parse extracts version from LABEL="rootfs-XXXX" +func Parse(label string) (string, error) { + re := regexp.MustCompile(`LABEL="rootfs-([0-9]+)"`) + m := re.FindStringSubmatch(label) + if len(m) < 2 { + return "", fmt.Errorf("version not found in: %s", label) + } + return m[1], nil +}