Skip to content

Commit b079d8d

Browse files
authored
feat: check for api key expiration date (#3049)
1 parent 45caf45 commit b079d8d

13 files changed

+470
-149
lines changed

internal/core/bootstrap.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,10 @@ func Bootstrap(config *BootstrapConfig) (exitCode int, result interface{}, err e
216216
}
217217
}
218218

219-
// Check CLI new version when exiting the bootstrap
219+
// Run checks after command has been executed
220220
defer func() { // if we plan to remove defer, do not forget logger is not set until cobra pre init func
221-
config.BuildInfo.checkVersion(ctx)
221+
// Check CLI new version and api key expiration date
222+
runAfterCommandChecks(ctx, config.BuildInfo.checkVersion, checkAPIKey)
222223
}()
223224

224225
if !config.DisableAliases {

internal/core/build_info.go

Lines changed: 6 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import (
66
"fmt"
77
"io"
88
"net/http"
9-
"os"
10-
"path/filepath"
119
"strings"
1210
"time"
1311

@@ -35,11 +33,10 @@ func (b *BuildInfo) MarshalJSON() ([]byte, error) {
3533
}
3634

3735
const (
38-
scwDisableCheckVersionEnv = "SCW_DISABLE_CHECK_VERSION"
39-
latestGithubReleaseURL = "https://api.github.com/repos/scaleway/scaleway-cli/releases/latest"
40-
latestVersionUpdateFileLocalName = "latest-cli-version"
41-
latestVersionRequestTimeout = 1 * time.Second
42-
userAgentPrefix = "scaleway-cli"
36+
scwDisableCheckVersionEnv = "SCW_DISABLE_CHECK_VERSION"
37+
latestGithubReleaseURL = "https://api.github.com/repos/scaleway/scaleway-cli/releases/latest"
38+
latestVersionRequestTimeout = 1 * time.Second
39+
userAgentPrefix = "scaleway-cli"
4340
)
4441

4542
// IsRelease returns true when the version of the CLI is an official release:
@@ -57,28 +54,11 @@ func (b *BuildInfo) GetUserAgent() string {
5754
}
5855

5956
func (b *BuildInfo) checkVersion(ctx context.Context) {
60-
cmd := extractMeta(ctx).command
61-
cmdDisableCheckVersion := cmd != nil && cmd.DisableVersionCheck
62-
if !b.IsRelease() || ExtractEnv(ctx, scwDisableCheckVersionEnv) == "true" || cmdDisableCheckVersion {
57+
if !b.IsRelease() || ExtractEnv(ctx, scwDisableCheckVersionEnv) == "true" {
6358
ExtractLogger(ctx).Debug("skipping check version")
6459
return
6560
}
6661

67-
latestVersionUpdateFilePath := getLatestVersionUpdateFilePath(ExtractCacheDir(ctx))
68-
69-
// do nothing if last refresh at during the last 24h
70-
if wasFileModifiedLast24h(latestVersionUpdateFilePath) {
71-
ExtractLogger(ctx).Debug("version was already checked during past 24 hours")
72-
return
73-
}
74-
75-
// do nothing if we cannot create the file
76-
err := createAndCloseFile(latestVersionUpdateFilePath)
77-
if err != nil {
78-
ExtractLogger(ctx).Debug(err.Error())
79-
return
80-
}
81-
8262
// pull latest version
8363
latestVersion, err := getLatestVersion(ExtractHTTPClient(ctx))
8464
if err != nil {
@@ -87,16 +67,12 @@ func (b *BuildInfo) checkVersion(ctx context.Context) {
8767
}
8868

8969
if b.Version.LessThan(latestVersion) {
90-
ExtractLogger(ctx).Warningf("a new version of scw is available (%s), beware that you are currently running %s\n", latestVersion, b.Version)
70+
ExtractLogger(ctx).Warningf("A new version of scw is available (%s), beware that you are currently running %s\n", latestVersion, b.Version)
9171
} else {
9272
ExtractLogger(ctx).Debugf("version is up to date (%s)\n", b.Version)
9373
}
9474
}
9575

96-
func getLatestVersionUpdateFilePath(cacheDir string) string {
97-
return filepath.Join(cacheDir, latestVersionUpdateFileLocalName)
98-
}
99-
10076
// getLatestVersion attempt to read the latest version of the remote file at latestVersionFileURL.
10177
func getLatestVersion(client *http.Client) (*version.Version, error) {
10278
ctx, cancelTimeout := context.WithTimeout(context.Background(), latestVersionRequestTimeout)
@@ -129,29 +105,3 @@ func getLatestVersion(client *http.Client) (*version.Version, error) {
129105

130106
return version.NewSemver(strings.TrimPrefix(jsonBody.TagName, "v"))
131107
}
132-
133-
// wasFileModifiedLast24h checks whether the file has been updated during last 24 hours.
134-
func wasFileModifiedLast24h(path string) bool {
135-
stat, err := os.Stat(path)
136-
if err != nil {
137-
return false
138-
}
139-
140-
yesterday := time.Now().AddDate(0, 0, -1)
141-
lastUpdate := stat.ModTime()
142-
return lastUpdate.After(yesterday)
143-
}
144-
145-
// createAndCloseFile creates a file and closes it. It returns true on succeed, false on failure.
146-
func createAndCloseFile(path string) error {
147-
err := os.MkdirAll(filepath.Dir(path), 0700)
148-
if err != nil {
149-
return fmt.Errorf("failed creating path %s: %s", path, err)
150-
}
151-
newFile, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_RDWR, 0600)
152-
if err != nil {
153-
return fmt.Errorf("failed creating file %s: %s", path, err)
154-
}
155-
156-
return newFile.Close()
157-
}

internal/core/build_info_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func Test_CheckVersion(t *testing.T) {
3333
Cmd: "scw plop",
3434
Check: TestCheckCombine(
3535
func(t *testing.T, ctx *CheckFuncCtx) {
36-
assert.Equal(t, "a new version of scw is available (2.5.4), beware that you are currently running 1.20.0\n", ctx.LogBuffer)
36+
assert.Equal(t, "A new version of scw is available (2.5.4), beware that you are currently running 1.20.0\n", ctx.LogBuffer)
3737
},
3838
),
3939
TmpHomeDir: true,
@@ -85,7 +85,7 @@ func Test_CheckVersion(t *testing.T) {
8585
Cmd: "scw plop",
8686
Check: TestCheckCombine(
8787
func(t *testing.T, ctx *CheckFuncCtx) {
88-
assert.Contains(t, ctx.LogBuffer, "a new version of scw is available (2.5.4), beware that you are currently running 1.0.0\n")
88+
assert.Contains(t, ctx.LogBuffer, "A new version of scw is available (2.5.4), beware that you are currently running 1.0.0\n")
8989
},
9090
),
9191
TmpHomeDir: true,

internal/core/checks.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package core
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"time"
9+
10+
iam "github.com/scaleway/scaleway-sdk-go/api/iam/v1alpha1"
11+
)
12+
13+
var (
14+
apiKeyExpireTime = 24 * time.Hour
15+
lastChecksFileLocalName = "last-cli-checks"
16+
)
17+
18+
type AfterCommandCheckFunc func(ctx context.Context)
19+
20+
// wasFileModifiedLast24h checks whether the file has been updated during last 24 hours.
21+
func wasFileModifiedLast24h(path string) bool {
22+
stat, err := os.Stat(path)
23+
if err != nil {
24+
return false
25+
}
26+
27+
yesterday := time.Now().AddDate(0, 0, -1)
28+
lastUpdate := stat.ModTime()
29+
return lastUpdate.After(yesterday)
30+
}
31+
32+
func getLatestVersionUpdateFilePath(cacheDir string) string {
33+
return filepath.Join(cacheDir, lastChecksFileLocalName)
34+
}
35+
36+
// createAndCloseFile creates a file and closes it. It returns true on succeed, false on failure.
37+
func createAndCloseFile(path string) error {
38+
err := os.MkdirAll(filepath.Dir(path), 0700)
39+
if err != nil {
40+
return fmt.Errorf("failed creating path %s: %s", path, err)
41+
}
42+
newFile, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_RDWR, 0600)
43+
if err != nil {
44+
return fmt.Errorf("failed creating file %s: %s", path, err)
45+
}
46+
47+
return newFile.Close()
48+
}
49+
50+
// runAfterCommandChecks execute checks after a command has been executed
51+
// skipped if command has disabled checks or was executed in the last 24 hours
52+
func runAfterCommandChecks(ctx context.Context, checkFuncs ...AfterCommandCheckFunc) {
53+
cmd := extractMeta(ctx).command
54+
cmdDisableCheck := cmd != nil && cmd.DisableAfterChecks
55+
if cmdDisableCheck {
56+
ExtractLogger(ctx).Debug("skipping after command checks")
57+
return
58+
}
59+
60+
lastChecksFilePath := getLatestVersionUpdateFilePath(ExtractCacheDir(ctx))
61+
62+
// do nothing if last refresh at during the last 24h
63+
if wasFileModifiedLast24h(lastChecksFilePath) {
64+
ExtractLogger(ctx).Debug("version was already checked during past 24 hours")
65+
return
66+
}
67+
68+
// do nothing if we cannot create the file
69+
err := createAndCloseFile(lastChecksFilePath)
70+
if err != nil {
71+
ExtractLogger(ctx).Debug(err.Error())
72+
return
73+
}
74+
75+
for _, checkFunc := range checkFuncs {
76+
checkFunc(ctx)
77+
}
78+
}
79+
80+
// Check if API Key is about to expire
81+
func checkAPIKey(ctx context.Context) {
82+
client := ExtractClient(ctx)
83+
if client == nil {
84+
return
85+
}
86+
accessKey, exists := client.GetAccessKey()
87+
if !exists {
88+
return
89+
}
90+
91+
api := iam.NewAPI(client)
92+
apiKey, err := api.GetAPIKey(&iam.GetAPIKeyRequest{
93+
AccessKey: accessKey,
94+
})
95+
if err != nil || apiKey.ExpiresAt == nil {
96+
return
97+
}
98+
now := time.Now()
99+
if apiKey.ExpiresAt.Before(now.Add(apiKeyExpireTime)) {
100+
expiresIn := apiKey.ExpiresAt.Sub(now).Truncate(time.Second).String()
101+
ExtractLogger(ctx).Warningf("Current api key expires in %s\n", expiresIn)
102+
}
103+
}

internal/core/checks_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package core
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"path/filepath"
7+
"reflect"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/alecthomas/assert"
13+
iam "github.com/scaleway/scaleway-sdk-go/api/iam/v1alpha1"
14+
"github.com/scaleway/scaleway-sdk-go/scw"
15+
)
16+
17+
func TestCheckAPIKey(t *testing.T) {
18+
testCommands := NewCommands(
19+
&Command{
20+
Namespace: "test",
21+
ArgSpecs: ArgSpecs{},
22+
ArgsType: reflect.TypeOf(testType{}),
23+
Run: func(ctx context.Context, argsI interface{}) (i interface{}, e error) {
24+
// Test command reload the client so the profile used is the edited one
25+
return "", ReloadClient(ctx)
26+
},
27+
})
28+
metadataKey := "ApiKey"
29+
30+
t.Run("basic", Test(&TestConfig{
31+
Commands: testCommands,
32+
TmpHomeDir: true,
33+
BeforeFunc: func(ctx *BeforeFuncCtx) error {
34+
api := iam.NewAPI(ctx.Client)
35+
accessKey, exists := ctx.Client.GetAccessKey()
36+
if !exists {
37+
return fmt.Errorf("missing access-key")
38+
}
39+
40+
apiKey, err := api.GetAPIKey(&iam.GetAPIKeyRequest{
41+
AccessKey: accessKey,
42+
})
43+
if err != nil {
44+
return err
45+
}
46+
expiresAt := time.Now().Add(time.Hour)
47+
apiKey, err = api.CreateAPIKey(&iam.CreateAPIKeyRequest{
48+
ApplicationID: apiKey.ApplicationID,
49+
UserID: apiKey.UserID,
50+
ExpiresAt: &expiresAt,
51+
Description: "test-cli-TestCheckAPIKey",
52+
})
53+
if err != nil {
54+
return err
55+
}
56+
if !*UpdateCassettes {
57+
apiKey.AccessKey = "SCWXXXXXXXXXXXXXXXXX"
58+
}
59+
60+
ctx.Meta[metadataKey] = apiKey
61+
cfg := &scw.Config{
62+
Profile: scw.Profile{
63+
AccessKey: &apiKey.AccessKey,
64+
SecretKey: apiKey.SecretKey,
65+
},
66+
}
67+
configPath := filepath.Join(ctx.OverrideEnv["HOME"], ".config", "scw", "config.yaml")
68+
69+
return cfg.SaveTo(configPath)
70+
},
71+
Cmd: "scw test",
72+
Check: TestCheckCombine(
73+
TestCheckExitCode(0),
74+
func(t *testing.T, ctx *CheckFuncCtx) {
75+
assert.True(t, strings.HasPrefix(ctx.LogBuffer, "Current api key expires in"))
76+
},
77+
),
78+
AfterFunc: func(ctx *AfterFuncCtx) error {
79+
return iam.NewAPI(ctx.Client).DeleteAPIKey(&iam.DeleteAPIKeyRequest{
80+
AccessKey: ctx.Meta[metadataKey].(*iam.APIKey).AccessKey,
81+
})
82+
},
83+
}))
84+
}

internal/core/command.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ type Command struct {
3535
// DisableTelemetry disable telemetry for the command.
3636
DisableTelemetry bool
3737

38-
// DisableVersionCheck disable the version check to avoid superfluous message
39-
DisableVersionCheck bool
38+
// DisableAfterChecks disable checks that run after the command to avoid superfluous message
39+
DisableAfterChecks bool
4040

4141
// Hidden hides the command form usage and auto-complete.
4242
Hidden bool

internal/core/context.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,6 @@ func ExtractCliConfigPath(ctx context.Context) string {
191191
func ReloadClient(ctx context.Context) error {
192192
var err error
193193
meta := extractMeta(ctx)
194-
// if client is from bootstrap we are probably running test
195-
// if we reload the client we loose the cassette recorder
196-
if meta.isClientFromBootstrapConfig {
197-
return nil
198-
}
199194
meta.Client, err = createClient(ctx, meta.httpClient, meta.BuildInfo, ExtractProfileName(ctx))
200195
return err
201196
}

0 commit comments

Comments
 (0)