feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
a000152423
commit
f02ec9977c
41 files changed
+3277
-151
No files matched your search
@@ -0,0 +1,84 @@
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
type AgentClient struct {
|
||||
socket string
|
||||
}
|
||||
|
||||
func NewAgentClient(socket string) *AgentClient {
|
||||
return &AgentClient{socket: socket}
|
||||
}
|
||||
|
||||
func (c *AgentClient) Start(ctx context.Context, request updateprotocol.Request) error {
|
||||
encoded, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "/v1/update", bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusAccepted {
|
||||
return nil
|
||||
}
|
||||
if response.StatusCode == http.StatusConflict {
|
||||
return errors.New("ein Software-Update läuft bereits")
|
||||
}
|
||||
return fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode)
|
||||
}
|
||||
|
||||
func (c *AgentClient) Status(ctx context.Context) (updateprotocol.Status, error) {
|
||||
response, err := c.do(ctx, http.MethodGet, "/v1/status", nil)
|
||||
if err != nil {
|
||||
return updateprotocol.Status{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return updateprotocol.Status{}, fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode)
|
||||
}
|
||||
var status updateprotocol.Status
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&status); err != nil {
|
||||
return updateprotocol.Status{}, fmt.Errorf("Status des Update-Agents lesen: %w", err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (c *AgentClient) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
||||
if c == nil || c.socket == "" {
|
||||
return nil, errors.New("Update-Agent ist nicht konfiguriert")
|
||||
}
|
||||
transport := &http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
dialer := net.Dialer{Timeout: time.Second}
|
||||
return dialer.DialContext(ctx, "unix", c.socket)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
request, err := http.NewRequestWithContext(ctx, method, "http://update-agent"+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, errors.New("Update-Agent ist nicht erreichbar")
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
var ErrCredentialMissing = errors.New("Update-Zugang ist noch nicht eingerichtet")
|
||||
|
||||
type State struct {
|
||||
CurrentVersion string
|
||||
LatestVersion string
|
||||
ReleaseURL string
|
||||
DesiredVersion string
|
||||
LastCheckedAt time.Time
|
||||
LastError string
|
||||
CredentialConfigured bool
|
||||
UpdateAvailable bool
|
||||
AgentAvailable bool
|
||||
AgentStatus updateprotocol.Status
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
cipher *secure.Cipher
|
||||
logger *slog.Logger
|
||||
apiURL string
|
||||
interval time.Duration
|
||||
agent *AgentClient
|
||||
httpClient *http.Client
|
||||
checkMu sync.Mutex
|
||||
stateMu sync.RWMutex
|
||||
state State
|
||||
checkSignal chan struct{}
|
||||
}
|
||||
|
||||
type releaseResponse struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
|
||||
func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket string, interval time.Duration, logger *slog.Logger) (*Manager, error) {
|
||||
settings, err := database.GetSoftwareUpdateSettings(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager := &Manager{
|
||||
store: database,
|
||||
cipher: cipher,
|
||||
logger: logger,
|
||||
apiURL: apiURL,
|
||||
interval: interval,
|
||||
agent: NewAgentClient(agentSocket),
|
||||
checkSignal: make(chan struct{}, 1),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 12 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
}
|
||||
manager.setStoredState(settings)
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Start(ctx context.Context) {
|
||||
go func() {
|
||||
m.refreshAgentStatus(ctx)
|
||||
initial := time.NewTimer(15 * time.Second)
|
||||
defer initial.Stop()
|
||||
var ticker *time.Ticker
|
||||
var tick <-chan time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ticker != nil {
|
||||
ticker.Stop()
|
||||
}
|
||||
return
|
||||
case <-initial.C:
|
||||
if ticker == nil {
|
||||
ticker = time.NewTicker(m.interval)
|
||||
tick = ticker.C
|
||||
}
|
||||
m.backgroundCheck(ctx)
|
||||
case <-tick:
|
||||
m.backgroundCheck(ctx)
|
||||
case <-m.checkSignal:
|
||||
m.backgroundCheck(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) backgroundCheck(ctx context.Context) {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
if err := m.CheckNow(checkCtx); err != nil && !errors.Is(err, ErrCredentialMissing) && !errors.Is(err, context.Canceled) {
|
||||
m.logger.Warn("software update check failed", "error", err)
|
||||
}
|
||||
m.refreshAgentStatus(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) TriggerCheck() {
|
||||
select {
|
||||
case m.checkSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) State() State {
|
||||
m.stateMu.RLock()
|
||||
defer m.stateMu.RUnlock()
|
||||
return m.state
|
||||
}
|
||||
|
||||
func (m *Manager) SaveCredential(ctx context.Context, username, token string) error {
|
||||
username, token = strings.TrimSpace(username), strings.TrimSpace(token)
|
||||
if username == "" || len(username) > 128 {
|
||||
return errors.New("Gitea-Benutzername fehlt oder ist zu lang")
|
||||
}
|
||||
if len(token) < 8 || len(token) > 1024 {
|
||||
return errors.New("Gitea-Token fehlt oder hat eine ungültige Länge")
|
||||
}
|
||||
ciphertext, nonce, err := m.cipher.Encrypt(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Update-Zugang verschlüsseln: %w", err)
|
||||
}
|
||||
if err := m.store.SaveSoftwareUpdateCredential(ctx, username, ciphertext, nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||
if err == nil {
|
||||
m.setStoredState(settings)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) CheckNow(ctx context.Context) error {
|
||||
m.checkMu.Lock()
|
||||
defer m.checkMu.Unlock()
|
||||
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.RegistryUsername == "" || len(settings.TokenCiphertext) == 0 || len(settings.TokenNonce) == 0 {
|
||||
m.setStoredState(settings)
|
||||
return ErrCredentialMissing
|
||||
}
|
||||
token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||
if err != nil {
|
||||
return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden"))
|
||||
}
|
||||
release, err := m.fetchLatest(ctx, token)
|
||||
if err != nil {
|
||||
return m.recordFailure(ctx, err)
|
||||
}
|
||||
checkedAt := time.Now().UTC()
|
||||
if err := m.store.RecordSoftwareUpdateSuccess(ctx, release.TagName, release.HTMLURL, checkedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
settings, err = m.store.GetSoftwareUpdateSettings(ctx)
|
||||
if err == nil {
|
||||
m.setStoredState(settings)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
||||
version = strings.TrimSpace(version)
|
||||
state := m.State()
|
||||
if !validReleaseVersion(version) || version != state.LatestVersion {
|
||||
return errors.New("die ausgewählte Version ist nicht das zuletzt geprüfte Release")
|
||||
}
|
||||
if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 {
|
||||
return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version")
|
||||
}
|
||||
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||
if err != nil || settings.RegistryUsername == "" {
|
||||
return ErrCredentialMissing
|
||||
}
|
||||
if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil {
|
||||
return err
|
||||
}
|
||||
request := updateprotocol.Request{Version: version, RegistryUsername: settings.RegistryUsername, RegistryToken: token}
|
||||
if err := m.agent.Start(ctx, request); err != nil {
|
||||
return err
|
||||
}
|
||||
settings, err = m.store.GetSoftwareUpdateSettings(ctx)
|
||||
if err == nil {
|
||||
m.setStoredState(settings)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, error) {
|
||||
status, err := m.agent.Status(ctx)
|
||||
m.stateMu.Lock()
|
||||
m.state.AgentAvailable = err == nil
|
||||
if err == nil {
|
||||
m.state.AgentStatus = status
|
||||
}
|
||||
m.stateMu.Unlock()
|
||||
return status, err
|
||||
}
|
||||
|
||||
func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseResponse, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
|
||||
if err != nil {
|
||||
return releaseResponse{}, errors.New("Update-Endpunkt ist ungültig")
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
|
||||
response, err := m.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return releaseResponse{}, errors.New("Git-Server ist momentan nicht erreichbar")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
||||
return releaseResponse{}, errors.New("Gitea-Zugang wurde abgelehnt")
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return releaseResponse{}, fmt.Errorf("Git-Server antwortet mit HTTP %d", response.StatusCode)
|
||||
}
|
||||
var release releaseResponse
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&release); err != nil {
|
||||
return releaseResponse{}, errors.New("Release-Antwort des Git-Servers ist ungültig")
|
||||
}
|
||||
if release.Draft || release.Prerelease || !validReleaseVersion(release.TagName) {
|
||||
return releaseResponse{}, errors.New("Git-Server lieferte kein gültiges stabiles Release")
|
||||
}
|
||||
release.HTMLURL = m.safeReleaseURL(release.HTMLURL)
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (m *Manager) safeReleaseURL(raw string) string {
|
||||
apiURL, apiErr := url.Parse(m.apiURL)
|
||||
releaseURL, releaseErr := url.Parse(raw)
|
||||
if apiErr != nil || releaseErr != nil || releaseURL.Scheme != "https" || !strings.EqualFold(apiURL.Hostname(), releaseURL.Hostname()) {
|
||||
return ""
|
||||
}
|
||||
return releaseURL.String()
|
||||
}
|
||||
|
||||
func (m *Manager) recordFailure(ctx context.Context, checkErr error) error {
|
||||
checkedAt := time.Now().UTC()
|
||||
if err := m.store.RecordSoftwareUpdateFailure(ctx, checkErr.Error(), checkedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if settings, err := m.store.GetSoftwareUpdateSettings(ctx); err == nil {
|
||||
m.setStoredState(settings)
|
||||
}
|
||||
return checkErr
|
||||
}
|
||||
|
||||
func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) {
|
||||
state := State{
|
||||
CurrentVersion: buildinfo.Version,
|
||||
LatestVersion: settings.LatestVersion,
|
||||
ReleaseURL: settings.ReleaseURL,
|
||||
DesiredVersion: settings.DesiredVersion,
|
||||
LastCheckedAt: settings.LastCheckedAt,
|
||||
LastError: settings.LastError,
|
||||
CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0,
|
||||
}
|
||||
if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil {
|
||||
state.UpdateAvailable = comparison < 0
|
||||
}
|
||||
m.stateMu.Lock()
|
||||
state.AgentAvailable = m.state.AgentAvailable
|
||||
state.AgentStatus = m.state.AgentStatus
|
||||
m.state = state
|
||||
m.stateMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) refreshAgentStatus(ctx context.Context) {
|
||||
statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
_, _ = m.UpdateStatus(statusCtx)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
const token = "test-token-that-must-stay-secret"
|
||||
var unavailable atomic.Bool
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "token "+token {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if unavailable.Load() {
|
||||
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"tag_name":"v1.3.1","html_url":"https://example.invalid/releases/v1.3.1","draft":false,"prerelease":false}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
keyFile := filepath.Join(t.TempDir(), "master-key")
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipher, err := secure.LoadCipher(keyFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := New(database, cipher, api.URL, filepath.Join(t.TempDir(), "missing-agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.SaveCredential(ctx, "registry-user", token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.CheckNow(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := manager.State()
|
||||
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError != "" {
|
||||
t.Fatalf("new release was not detected: %#v", state)
|
||||
}
|
||||
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(settings.TokenCiphertext), token) {
|
||||
t.Fatal("registry token was stored as plaintext")
|
||||
}
|
||||
|
||||
unavailable.Store(true)
|
||||
if err := manager.CheckNow(ctx); err == nil {
|
||||
t.Fatal("unavailable Gitea endpoint was reported as successful")
|
||||
}
|
||||
state = manager.State()
|
||||
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError == "" {
|
||||
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
type semanticVersion struct {
|
||||
major int
|
||||
minor int
|
||||
patch int
|
||||
}
|
||||
|
||||
func parseVersion(raw string) (semanticVersion, error) {
|
||||
if !strings.HasPrefix(raw, "v") || strings.ContainsAny(raw, "+-") {
|
||||
return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(raw, "v"), ".")
|
||||
if len(parts) != 3 {
|
||||
return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
|
||||
}
|
||||
values := make([]int, 3)
|
||||
for index, part := range parts {
|
||||
if part == "" || (len(part) > 1 && part[0] == '0') {
|
||||
return semanticVersion{}, fmt.Errorf("version contains an invalid number")
|
||||
}
|
||||
value, err := strconv.Atoi(part)
|
||||
if err != nil || value < 0 || value > 1_000_000 {
|
||||
return semanticVersion{}, fmt.Errorf("version contains an invalid number")
|
||||
}
|
||||
values[index] = value
|
||||
}
|
||||
return semanticVersion{major: values[0], minor: values[1], patch: values[2]}, nil
|
||||
}
|
||||
|
||||
func compareVersions(left, right string) (int, error) {
|
||||
a, err := parseVersion(left)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
b, err := parseVersion(right)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
av, bv := []int{a.major, a.minor, a.patch}, []int{b.major, b.minor, b.patch}
|
||||
for index := range av {
|
||||
if av[index] < bv[index] {
|
||||
return -1, nil
|
||||
}
|
||||
if av[index] > bv[index] {
|
||||
return 1, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func validReleaseVersion(version string) bool {
|
||||
if !updateprotocol.ValidVersion(version) {
|
||||
return false
|
||||
}
|
||||
_, err := parseVersion(version)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package appupdate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompareVersionsUsesStrictStableSemanticVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
left string
|
||||
right string
|
||||
want int
|
||||
}{
|
||||
{left: "v1.2.2", right: "v1.2.3", want: -1},
|
||||
{left: "v2.0.0", right: "v1.99.99", want: 1},
|
||||
{left: "v1.2.2", right: "v1.2.2", want: 0},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, err := compareVersions(test.left, test.right)
|
||||
if err != nil || got != test.want {
|
||||
t.Errorf("compareVersions(%q, %q) = %d, %v; want %d", test.left, test.right, got, err, test.want)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{"1.2.3", "v01.2.3", "v1.2", "v1.2.3-rc.1", "v1.2.3+build"} {
|
||||
if validReleaseVersion(invalid) {
|
||||
t.Errorf("invalid release version accepted: %q", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user