feat(cli): apply coordinated updates during maintenance
This commit is contained in:
21 files changed
+990
-457
No files matched your search
@@ -0,0 +1,117 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var releaseTagPattern = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
|
||||
var githubRepoPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
|
||||
|
||||
// InstallTarget pins both the installer and the source tree to one revision.
|
||||
// Release is set only when installing checksum-verified published assets.
|
||||
type InstallTarget struct {
|
||||
Release string
|
||||
Revision string
|
||||
Script string
|
||||
}
|
||||
|
||||
// InstallerSource uses the same GitHub client and token as release discovery.
|
||||
type InstallerSource struct {
|
||||
github github
|
||||
repo string
|
||||
}
|
||||
|
||||
func NewInstallerSource(repoURL string) (InstallerSource, error) {
|
||||
repo := officialRepo
|
||||
if repoURL != "" {
|
||||
if strings.HasPrefix(repoURL, "[email protected]:") {
|
||||
repo = strings.TrimPrefix(repoURL, "[email protected]:")
|
||||
} else {
|
||||
u, err := url.Parse(repoURL)
|
||||
if err != nil || u.Hostname() != "github.com" || (u.Scheme != "https" && u.Scheme != "ssh") || u.RawQuery != "" || u.Fragment != "" {
|
||||
return InstallerSource{}, fmt.Errorf("FELIS_REPO_URL must name a GitHub repository over HTTPS or SSH")
|
||||
}
|
||||
repo = strings.TrimPrefix(u.Path, "/")
|
||||
}
|
||||
repo = strings.TrimSuffix(strings.TrimSuffix(repo, "/"), ".git")
|
||||
if !githubRepoPattern.MatchString(repo) || strings.Contains(repo, "..") {
|
||||
return InstallerSource{}, fmt.Errorf("FELIS_REPO_URL must name a GitHub owner/repository")
|
||||
}
|
||||
}
|
||||
return InstallerSource{github: newGitHub(), repo: repo}, nil
|
||||
}
|
||||
|
||||
func (s InstallerSource) RepoURL() string { return "https://github.com/" + s.repo + ".git" }
|
||||
|
||||
// Prepare downloads the complete script before any host changes. A moving ref
|
||||
// (including main) is resolved once; the installer receives that same full SHA.
|
||||
func (s InstallerSource) Prepare(ctx context.Context, release, ref string) (InstallTarget, error) {
|
||||
if ref == "" {
|
||||
endpoint := "releases/latest"
|
||||
if release != "" {
|
||||
endpoint = "releases/tags/" + url.PathEscape(release)
|
||||
}
|
||||
tag, err := s.github.releaseTag(ctx, s.repo, endpoint)
|
||||
if err != nil {
|
||||
return InstallTarget{}, err
|
||||
}
|
||||
if !releaseTagPattern.MatchString(tag) || (release != "" && tag != release) {
|
||||
return InstallTarget{}, fmt.Errorf("unexpected Felis release tag %q", tag)
|
||||
}
|
||||
release, ref = tag, tag
|
||||
}
|
||||
resp, err := s.github.get(ctx, "/repos/"+s.repo+"/commits/"+url.PathEscape(ref), "application/vnd.github+json")
|
||||
if err != nil {
|
||||
return InstallTarget{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return InstallTarget{}, fmt.Errorf("github: resolve ref %q: HTTP %d", ref, resp.StatusCode)
|
||||
}
|
||||
var commit struct {
|
||||
SHA string `json:"sha"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&commit); err != nil {
|
||||
return InstallTarget{}, fmt.Errorf("github: decode commit: %w", err)
|
||||
}
|
||||
if _, err := hex.DecodeString(commit.SHA); err != nil || len(commit.SHA) != 40 {
|
||||
return InstallTarget{}, fmt.Errorf("github: ref %q did not resolve to a full commit SHA", ref)
|
||||
}
|
||||
commit.SHA = strings.ToLower(commit.SHA)
|
||||
if _, err := hex.DecodeString(ref); err == nil && len(ref) == 40 && !strings.EqualFold(ref, commit.SHA) {
|
||||
return InstallTarget{}, fmt.Errorf("github: resolved commit %s differs from requested %s", commit.SHA, ref)
|
||||
}
|
||||
script, err := s.script(ctx, commit.SHA)
|
||||
if err != nil {
|
||||
return InstallTarget{}, err
|
||||
}
|
||||
return InstallTarget{Release: release, Revision: commit.SHA, Script: script}, nil
|
||||
}
|
||||
|
||||
func (s InstallerSource) script(ctx context.Context, revision string) (string, error) {
|
||||
resp, err := s.github.get(ctx, "/repos/"+s.repo+"/contents/deploy/bootstrap.sh?ref="+revision, "application/vnd.github.raw+json")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("github: download installer at %s: HTTP %d", revision, resp.StatusCode)
|
||||
}
|
||||
const limit = 2 << 20
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("github: read installer: %w", err)
|
||||
}
|
||||
if len(raw) > limit || !strings.HasPrefix(string(raw), "#!/") {
|
||||
return "", fmt.Errorf("github: installer is oversized or is not a shell script")
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
Reference in new issue
Block a user