Skip to content

Commit

Permalink
Check Regal version at start-up (#824)
Browse files Browse the repository at this point in the history
* Check Regal version at start-up

This adds functionality to the regal LS and lint commands what will
check the current version against the latest release in GH.

In order to keep the number of requests down, the version is cached to
disk in the .regal dir. The task runs in another goroutine to keep
things fast too.

Config has been updated with a features key, and
REGAL_FEATURES_REMOTE_CHECK_VERSION can also be set to disable to
functionality.

Sharing for review, if we think it looks good, I'll add some docs and we
can get this in.

Signed-off-by: Charlie Egan <[email protected]>

* Share logic for sharing of defaulting config

Signed-off-by: Charlie Egan <[email protected]>

* Rename var to disable the check version feature

Signed-off-by: Charlie Egan <[email protected]>

* Use global config dir for version state

Signed-off-by: Charlie Egan <[email protected]>

* Use shorter var name

Signed-off-by: Charlie Egan <[email protected]>

* Extract check to func

* Add docs

Signed-off-by: Charlie Egan <[email protected]>

* Set lang in docs

Signed-off-by: Charlie Egan <[email protected]>

---------

Signed-off-by: Charlie Egan <[email protected]>
  • Loading branch information
charlieegan3 committed Jun 12, 2024
1 parent b6588dc commit 90b2bcc
Show file tree
Hide file tree
Showing 12 changed files with 608 additions and 111 deletions.
3 changes: 3 additions & 0 deletions bundle/regal/config/provided/data.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
features:
remote:
check-version: true
rules:
bugs:
constant-condition:
Expand Down
35 changes: 34 additions & 1 deletion cmd/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@ import (
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"

"github.com/open-policy-agent/opa/bundle"
"github.com/open-policy-agent/opa/metrics"
"github.com/open-policy-agent/opa/topdown"

rbundle "github.com/styrainc/regal/bundle"
rio "github.com/styrainc/regal/internal/io"
regalmetrics "github.com/styrainc/regal/internal/metrics"
"github.com/styrainc/regal/internal/update"
"github.com/styrainc/regal/pkg/config"
"github.com/styrainc/regal/pkg/linter"
"github.com/styrainc/regal/pkg/report"
"github.com/styrainc/regal/pkg/reporter"
"github.com/styrainc/regal/pkg/version"
)

type lintCommandParams struct {
Expand Down Expand Up @@ -243,7 +247,13 @@ func lint(args []string, params *lintCommandParams) (report.Report, error) {
m.Timer(regalmetrics.RegalConfigSearch).Stop()
}

regal := linter.NewLinter().
// regal rules are loaded here and passed to the linter separately
// as the configuration is also used to determine feature toggles
// and the defaults from the data.yaml here.
regalRules := rio.MustLoadRegalBundleFS(rbundle.Bundle)

regal := linter.NewEmptyLinter().
WithAddedBundle(regalRules).
WithDisableAll(params.disableAll).
WithDisabledCategories(params.disableCategory.v...).
WithDisabledRules(params.disable.v...).
Expand Down Expand Up @@ -312,6 +322,8 @@ func lint(args []string, params *lintCommandParams) (report.Report, error) {
m.Timer(regalmetrics.RegalConfigParse).Stop()
}

go updateCheckAndWarn(params, regalRules, &userConfig)

result, err := regal.Lint(ctx)
if err != nil {
return report.Report{}, formatError(params.format, fmt.Errorf("error(s) encountered while linting: %w", err))
Expand All @@ -325,6 +337,27 @@ func lint(args []string, params *lintCommandParams) (report.Report, error) {
return result, rep.Publish(ctx, result) //nolint:wrapcheck
}

func updateCheckAndWarn(params *lintCommandParams, regalRules bundle.Bundle, userConfig *config.Config) {
mergedConfig, err := config.LoadConfigWithDefaultsFromBundle(&regalRules, userConfig)
if err != nil {
if params.debug {
log.Printf("failed to merge user config with default config when checking version: %v", err)
}

return
}

if mergedConfig.Features.Remote.CheckVersion &&
os.Getenv(update.CheckVersionDisableEnvVar) != "" {
update.CheckAndWarn(update.Options{
CurrentVersion: version.Version,
CurrentTime: time.Now().UTC(),
Debug: params.debug,
StateDir: config.GlobalDir(),
}, os.Stderr)
}
}

func getReporter(format string, outputWriter io.Writer) (reporter.Reporter, error) {
switch format {
case formatPretty:
Expand Down
27 changes: 27 additions & 0 deletions docs/remote-features.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Remote Features

This page outlines the features of Regal that need internet access to function.

## Checking for Updates

Regal will check for updates on startup. If a new version is available,
Regal will notify you by writing a message in stderr.

An example of such a message is:

```txt
A new version of Regal is available (v0.23.1). You are running v0.23.0.
See https://github.com/StyraInc/regal/releases/tag/v0.23.1 for the latest release.
```

This message is based on the local version set in the Regal binary, and **no
user data is sent** to GitHub where the releases are hosted.

This same function will also write to the file at: `$HOME/.config/regal/latest_version.json`,
this is used as a cache of the latest version to avoid consuming excessive
GitHub API rate limits when using Regal.

This functionality can be disabled in two ways:

* Using `.regal/config.yaml`: set `features.remote.check-version` to `false`.
* Using an environment variable: set `REGAL_DISABLE_CHECK_VERSION` to `true`.
38 changes: 35 additions & 3 deletions internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ import (
"strconv"
"strings"
"sync"
"time"

"github.com/sourcegraph/jsonrpc2"
"gopkg.in/yaml.v3"

"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/format"

"github.com/styrainc/regal/bundle"
rio "github.com/styrainc/regal/internal/io"
"github.com/styrainc/regal/internal/lsp/cache"
"github.com/styrainc/regal/internal/lsp/clients"
"github.com/styrainc/regal/internal/lsp/commands"
Expand All @@ -30,10 +33,12 @@ import (
"github.com/styrainc/regal/internal/lsp/types"
"github.com/styrainc/regal/internal/lsp/uri"
rparse "github.com/styrainc/regal/internal/parse"
"github.com/styrainc/regal/internal/update"
"github.com/styrainc/regal/internal/util"
"github.com/styrainc/regal/pkg/config"
"github.com/styrainc/regal/pkg/fixer/fixes"
"github.com/styrainc/regal/pkg/linter"
"github.com/styrainc/regal/pkg/version"
)

const (
Expand Down Expand Up @@ -278,6 +283,13 @@ func (l *LanguageServer) StartConfigWorker(ctx context.Context) {
return
}

regalRules, err := rio.LoadRegalBundleFS(bundle.Bundle)
if err != nil {
l.logError(fmt.Errorf("failed to load regal bundle for defaulting of user config: %w", err))

return
}

for {
select {
case <-ctx.Done():
Expand All @@ -290,24 +302,44 @@ func (l *LanguageServer) StartConfigWorker(ctx context.Context) {
continue
}

var loadedConfig config.Config
var userConfig config.Config

err = yaml.NewDecoder(configFile).Decode(&loadedConfig)
err = yaml.NewDecoder(configFile).Decode(&userConfig)
if err != nil && !errors.Is(err, io.EOF) {
l.logError(fmt.Errorf("failed to reload config: %w", err))

return
}

mergedConfig, err := config.LoadConfigWithDefaultsFromBundle(&regalRules, &userConfig)
if err != nil {
l.logError(fmt.Errorf("failed to load config: %w", err))

return
}

// if the config is now blank, then we need to clear it
l.loadedConfigLock.Lock()
if errors.Is(err, io.EOF) {
l.loadedConfig = nil
} else {
l.loadedConfig = &loadedConfig
l.loadedConfig = &mergedConfig
}
l.loadedConfigLock.Unlock()

//nolint:contextcheck
go func() {
if l.loadedConfig.Features.Remote.CheckVersion &&
os.Getenv(update.CheckVersionDisableEnvVar) != "" {
update.CheckAndWarn(update.Options{
CurrentVersion: version.Version,
CurrentTime: time.Now().UTC(),
Debug: false,
StateDir: config.GlobalDir(),
}, os.Stderr)
}
}()

l.diagnosticRequestWorkspace <- "config file changed"
case <-l.configWatcher.Drop:
l.loadedConfigLock.Lock()
Expand Down
186 changes: 186 additions & 0 deletions internal/update/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//nolint:errcheck
package update

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"

"github.com/open-policy-agent/opa/rego"

_ "embed"
)

//go:embed update.rego
var updateModule string

const CheckVersionDisableEnvVar = "REGAL_DISABLE_VERSION_CHECK"

type Options struct {
CurrentVersion string
CurrentTime time.Time

StateDir string

ReleaseServerHost string
ReleaseServerPath string

CTAURLPrefix string

Debug bool
}

type latestVersionFileContents struct {
LatestVersion string `json:"latest_version"`
CheckedAt time.Time `json:"checked_at"`
}

func CheckAndWarn(opts Options, w io.Writer) {
// this is a shortcut heuristic to avoid and version checking
// when in dev/test etc.
if !strings.HasPrefix(opts.CurrentVersion, "v") {
return
}

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

latestVersion, err := getLatestVersion(ctx, opts)
if err != nil {
if opts.Debug {
w.Write([]byte(err.Error()))
}

return
}

regoArgs := []func(*rego.Rego){
rego.Module("update.rego", updateModule),
rego.Query(`data.update.needs_update`),
rego.Input(map[string]interface{}{
"current_version": opts.CurrentVersion,
"latest_version": latestVersion,
}),
}

rs, err := rego.New(regoArgs...).Eval(context.Background())
if err != nil {
if opts.Debug {
w.Write([]byte(err.Error()))
}

return
}

if !rs.Allowed() {
if opts.Debug {
w.Write([]byte("Regal is up to date"))
}

return
}

ctaURLPrefix := "https://github.com/StyraInc/regal/releases/tag/"
if opts.CTAURLPrefix != "" {
ctaURLPrefix = opts.CTAURLPrefix
}

ctaURL := ctaURLPrefix + latestVersion

tmpl := `A new version of Regal is available (%s). You are running %s.
See %s for the latest release.
`

w.Write([]byte(fmt.Sprintf(tmpl, latestVersion, opts.CurrentVersion, ctaURL)))
}

func getLatestVersion(ctx context.Context, opts Options) (string, error) {
if opts.StateDir != "" {
// first, attempt to get the file from previous invocations to save on remote calls
latestVersionFilePath := filepath.Join(opts.StateDir, "latest_version.json")

_, err := os.Stat(latestVersionFilePath)
if err == nil {
var preExistingState latestVersionFileContents

file, err := os.Open(latestVersionFilePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}

err = json.NewDecoder(file).Decode(&preExistingState)
if err != nil {
return "", fmt.Errorf("failed to decode existing version state file: %w", err)
}

if opts.CurrentTime.Sub(preExistingState.CheckedAt) < 3*24*time.Hour {
return preExistingState.LatestVersion, nil
}
}
}

client := http.Client{}

releaseServerHost := "https://api.github.com"
if opts.ReleaseServerHost != "" {
releaseServerHost = strings.TrimSuffix(opts.ReleaseServerHost, "/")

if !strings.HasPrefix(releaseServerHost, "http") {
releaseServerHost = "https://" + releaseServerHost
}
}

releaseServerURL, err := url.Parse(releaseServerHost)
if err != nil {
return "", fmt.Errorf("failed to parse release server URL: %w", err)
}

releaseServerPath := "/repos/styrainc/regal/releases/latest"
if opts.ReleaseServerPath != "" {
releaseServerPath = opts.ReleaseServerPath
}

releaseServerURL.Path = releaseServerPath

req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseServerURL.String(), nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}

resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()

var responseData struct {
TagName string `json:"tag_name"`
}

err = json.NewDecoder(resp.Body).Decode(&responseData)
if err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}

stateBs, err := json.MarshalIndent(latestVersionFileContents{
LatestVersion: responseData.TagName,
CheckedAt: opts.CurrentTime,
}, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal state file: %w", err)
}

err = os.WriteFile(opts.StateDir+"/latest_version.json", stateBs, 0o600)
if err != nil {
return "", fmt.Errorf("failed to write state file: %w", err)
}

return responseData.TagName, nil
}
Loading

0 comments on commit 90b2bcc

Please sign in to comment.