feat: releases from GitHub first, then git.kebcloud.no; releases sets others
Updates and install.sh try the GitHub mirror, then git.kebcloud.no, each with a short timeout, and download from the first server with the release, checked against its checksums.txt. The releases config option (and LAZYSECRETS_RELEASES for install.sh) lists other servers. The release workflow copies each release to GitHub when GH_RELEASE_TOKEN is set. The status pane's issues link points to GitHub. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c00f4b14c4
commit
b3c1911ba5
13 files changed
+329
-70
No files matched your search
@@ -14,6 +14,7 @@ import (
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -40,6 +41,10 @@ type Config struct {
|
||||
// background; defaults to true. U and lazysecrets update work either
|
||||
// way.
|
||||
UpdateCheck *bool `yaml:"updateCheck"`
|
||||
// Releases are the repositories updates come from, tried in order:
|
||||
// on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, then
|
||||
// git.kebcloud.no (update.DefaultServers).
|
||||
Releases []string `yaml:"releases"`
|
||||
// Cache keeps what was loaded (never values) between runs, so lists
|
||||
// show at once and refresh in the background; defaults to true.
|
||||
Cache *bool `yaml:"cache"`
|
||||
@@ -211,6 +216,11 @@ func (c Config) validate(path string) error {
|
||||
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
|
||||
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
|
||||
}
|
||||
for i, r := range c.Releases {
|
||||
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
|
||||
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
|
||||
}
|
||||
}
|
||||
for i, v := range c.Providers.Azure.Vaults {
|
||||
if !strings.HasPrefix(v, "https://") {
|
||||
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
||||
|
||||
@@ -70,6 +70,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
||||
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
|
||||
"duration": "timeout: soon\n",
|
||||
"view": "sourcesView: tree\n",
|
||||
"releases": "releases: [git.example.com/team/lazysecrets]\n",
|
||||
"http": "releases: [http://git.example.com/team/lazysecrets]\n",
|
||||
} {
|
||||
if _, err := Load(write(t, content)); err == nil {
|
||||
t.Errorf("%s: no error", name)
|
||||
@@ -113,6 +115,13 @@ theme:
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleases(t *testing.T) {
|
||||
c, err := Load(write(t, "releases: [https://git.example.com/team/lazysecrets]\n"))
|
||||
if err != nil || len(c.Releases) != 1 {
|
||||
t.Errorf("releases = %v, %v", c.Releases, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDelay(t *testing.T) {
|
||||
c, err := Load(write(t, "loadDelay: 300ms\n"))
|
||||
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
|
||||
|
||||
@@ -122,7 +122,9 @@ func (m Model) statusFields() []panes.Field {
|
||||
repo := strings.TrimPrefix(update.DefaultRepo, "https://")
|
||||
add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"})
|
||||
add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo})
|
||||
add(panes.Field{Label: "issues", Value: repo + "/issues", URL: update.DefaultRepo + "/issues"})
|
||||
// Issues are on the GitHub mirror.
|
||||
github := strings.TrimPrefix(update.GitHubRepo, "https://")
|
||||
add(panes.Field{Label: "issues", Value: github + "/issues", URL: update.GitHubRepo + "/issues"})
|
||||
add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"})
|
||||
add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"})
|
||||
return append(out, m.changelog...)
|
||||
|
||||
+91
-30
@@ -3,6 +3,9 @@
|
||||
// against the release's checksums.txt, tried (--version), then renamed
|
||||
// over the binary. A running lazysecrets keeps its old binary until it
|
||||
// restarts.
|
||||
//
|
||||
// Releases are published in more than one place; each is tried in turn
|
||||
// (GitHub, then git.kebcloud.no), so one being down doesn't stop updates.
|
||||
package update
|
||||
|
||||
import (
|
||||
@@ -18,6 +21,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
@@ -30,12 +34,20 @@ import (
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// Where releases are published.
|
||||
// DefaultRepo is lazysecrets' home: its source, docs and releases.
|
||||
// GitHubRepo mirrors it, with the issues and the releases too.
|
||||
const (
|
||||
DefaultAPI = "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets"
|
||||
DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets"
|
||||
GitHubRepo = "https://github.com/karleinarb/lazysecrets"
|
||||
)
|
||||
|
||||
// DefaultServers are where releases are looked for, in order.
|
||||
var DefaultServers = []string{GitHubRepo, DefaultRepo}
|
||||
|
||||
// latestTimeout bounds asking one server for the latest release, so a
|
||||
// server that's down leaves time for the next.
|
||||
const latestTimeout = 5 * time.Second
|
||||
|
||||
// maxDownload bounds what's read from the server.
|
||||
const maxDownload = 200 << 20
|
||||
|
||||
@@ -60,15 +72,20 @@ func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
|
||||
// and a terminal shows them as their text.
|
||||
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
||||
|
||||
// Client talks to the release server.
|
||||
// Client talks to the release servers.
|
||||
type Client struct {
|
||||
API string // the repository's API, DefaultAPI when empty
|
||||
Repo string // the repository's web address, DefaultRepo when empty
|
||||
HTTP *http.Client
|
||||
// Servers are the repositories' web addresses, on GitHub or a Gitea
|
||||
// or Forgejo server, tried in order; DefaultServers when empty.
|
||||
Servers []string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func (c *Client) api() string { return cmpOr(c.API, DefaultAPI) }
|
||||
func (c *Client) repo() string { return cmpOr(c.Repo, DefaultRepo) }
|
||||
func (c *Client) servers() []string {
|
||||
if len(c.Servers) > 0 {
|
||||
return c.Servers
|
||||
}
|
||||
return DefaultServers
|
||||
}
|
||||
|
||||
func (c *Client) client() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
@@ -77,25 +94,50 @@ func (c *Client) client() *http.Client {
|
||||
return &http.Client{Timeout: 2 * time.Minute}
|
||||
}
|
||||
|
||||
func cmpOr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
// API is a repository's API: GitHub's for github.com, else Gitea's (and
|
||||
// Forgejo's).
|
||||
func API(repo string) (string, error) {
|
||||
u, err := url.Parse(strings.TrimSuffix(repo, "/"))
|
||||
if err != nil || u.Host == "" || strings.Count(u.Path, "/") != 2 {
|
||||
return "", fmt.Errorf("%q isn't a repository's address, like %s", repo, GitHubRepo)
|
||||
}
|
||||
return b
|
||||
if u.Host == "github.com" {
|
||||
return "https://api.github.com/repos" + u.Path, nil
|
||||
}
|
||||
return u.Scheme + "://" + u.Host + "/api/v1/repos" + u.Path, nil
|
||||
}
|
||||
|
||||
// Latest is the newest release (pre-releases aside).
|
||||
// Latest is the newest release (pre-releases aside), from the first
|
||||
// server that answers.
|
||||
func (c *Client) Latest(ctx context.Context) (Release, error) {
|
||||
b, err := c.get(ctx, c.api()+"/releases/latest", 1<<20)
|
||||
var errs []error
|
||||
for _, repo := range c.servers() {
|
||||
r, err := c.latest(ctx, repo)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return Release{}, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
|
||||
api, err := API(repo)
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
||||
defer cancel()
|
||||
b, err := c.get(ctx, api+"/releases/latest", 1<<20)
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
var r Release
|
||||
if err := json.Unmarshal(b, &r); err != nil {
|
||||
return Release{}, fmt.Errorf("reading the latest release: %w", err)
|
||||
return Release{}, fmt.Errorf("reading the latest release from %s: %w", repo, err)
|
||||
}
|
||||
if !semver.IsValid(r.Tag) {
|
||||
return Release{}, fmt.Errorf("latest release %q isn't a version", r.Tag)
|
||||
return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
@@ -147,23 +189,10 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
||||
}
|
||||
version := strings.TrimPrefix(r.Tag, "v")
|
||||
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
|
||||
base := c.repo() + "/releases/download/" + r.Tag + "/"
|
||||
|
||||
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
|
||||
archive, err := c.download(ctx, r.Tag, name+".tar.gz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want, err := checksum(sums, name+".tar.gz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
archive, err := c.get(ctx, base+name+".tar.gz", maxDownload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got := sha256.Sum256(archive); hex.EncodeToString(got[:]) != want {
|
||||
return fmt.Errorf("%s.tar.gz doesn't match checksums.txt: damaged, or not the release", name)
|
||||
}
|
||||
bin, err := extract(archive, path.Join(name, "lazysecrets"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -198,6 +227,38 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// download fetches a release's file from the first server that has it,
|
||||
// checked against that server's checksums.txt. A file that doesn't match
|
||||
// stops there: it's damaged, or not the release.
|
||||
func (c *Client) download(ctx context.Context, tag, file string) ([]byte, error) {
|
||||
var errs []error
|
||||
for _, repo := range c.servers() {
|
||||
base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/"
|
||||
sctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
||||
sums, err := c.get(sctx, base+"checksums.txt", 1<<20)
|
||||
cancel()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
want, err := checksum(sums, file)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", repo, err))
|
||||
continue
|
||||
}
|
||||
b, err := c.get(ctx, base+file, maxDownload)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
if got := sha256.Sum256(b); hex.EncodeToString(got[:]) != want {
|
||||
return nil, fmt.Errorf("%s from %s doesn't match checksums.txt: damaged, or not the release", file, repo)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -70,13 +70,13 @@ func server(t *testing.T, tamper bool) *httptest.Server {
|
||||
sum[0] ^= 0xff
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
|
||||
mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
|
||||
fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`)
|
||||
})
|
||||
mux.HandleFunc("/repo/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
|
||||
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
|
||||
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
|
||||
})
|
||||
mux.HandleFunc("/repo/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Write(archive.Bytes())
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
@@ -89,7 +89,7 @@ func TestInstall(t *testing.T) {
|
||||
t.Skip("releases are for macOS and Linux")
|
||||
}
|
||||
srv := server(t, false)
|
||||
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
|
||||
c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}
|
||||
r, err := c.Latest(context.Background())
|
||||
if err != nil || r.Tag != "v0.2.0" {
|
||||
t.Fatalf("latest %+v %v", r, err)
|
||||
@@ -107,12 +107,59 @@ func TestInstall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A server that's down, or doesn't have the release, is skipped.
|
||||
func TestFallsBackToTheNextServer(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
||||
t.Skip("releases are for macOS and Linux")
|
||||
}
|
||||
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "down", http.StatusBadGateway)
|
||||
}))
|
||||
t.Cleanup(down.Close)
|
||||
srv := server(t, false)
|
||||
c := &Client{Servers: []string{down.URL + "/keb/lazysecrets", srv.URL + "/keb/lazysecrets"}}
|
||||
r, err := c.Latest(context.Background())
|
||||
if err != nil || r.Tag != "v0.2.0" {
|
||||
t.Fatalf("latest %+v %v", r, err)
|
||||
}
|
||||
exe := filepath.Join(t.TempDir(), "lazysecrets")
|
||||
os.WriteFile(exe, []byte("old"), 0o755)
|
||||
if err := c.Install(context.Background(), r, exe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b, _ := os.ReadFile(exe); !strings.Contains(string(b), "echo lazysecrets 0.2.0") {
|
||||
t.Errorf("not replaced: %q", b)
|
||||
}
|
||||
|
||||
// Every server down: both say why.
|
||||
c = &Client{Servers: []string{down.URL + "/a/b", down.URL + "/c/d"}}
|
||||
if _, err := c.Latest(context.Background()); err == nil || strings.Count(err.Error(), "502") != 2 {
|
||||
t.Errorf("all down: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI(t *testing.T) {
|
||||
for repo, want := range map[string]string{
|
||||
"https://github.com/karleinarb/lazysecrets": "https://api.github.com/repos/karleinarb/lazysecrets",
|
||||
"https://git.kebcloud.no/keb/lazysecrets/": "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets",
|
||||
"https://codeberg.org/someone/lazysecrets": "https://codeberg.org/api/v1/repos/someone/lazysecrets",
|
||||
"https://git.kebcloud.no/keb": "",
|
||||
"git.kebcloud.no/keb/lazysecrets": "",
|
||||
"https://github.com/karleinarb/lazysecrets/x": "",
|
||||
} {
|
||||
got, err := API(repo)
|
||||
if got != want || (err != nil) != (want == "") {
|
||||
t.Errorf("API(%q) = %q, %v", repo, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRefusesATamperedArchive(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
||||
t.Skip("releases are for macOS and Linux")
|
||||
}
|
||||
srv := server(t, true)
|
||||
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
|
||||
c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}
|
||||
exe := filepath.Join(t.TempDir(), "lazysecrets")
|
||||
os.WriteFile(exe, []byte("old"), 0o755)
|
||||
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
|
||||
|
||||
Reference in new issue
Block a user