-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #75 from ninech/add-initial-repo-check
use git information service on app creation and updates
- Loading branch information
Showing
10 changed files
with
1,113 additions
and
191 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
package validation | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"fmt" | ||
"os" | ||
"strings" | ||
|
||
apps "github.com/ninech/apis/apps/v1alpha1" | ||
"github.com/ninech/nctl/api/util" | ||
"github.com/ninech/nctl/internal/format" | ||
"github.com/theckman/yacspin" | ||
) | ||
|
||
// RepositoryValidator validates a git repository | ||
type RepositoryValidator struct { | ||
GitInformationServiceURL string | ||
Token string | ||
Debug bool | ||
} | ||
|
||
// Validate validates the repository access and shows a visual spinner while doing so | ||
func (v *RepositoryValidator) Validate(ctx context.Context, git *apps.ApplicationGitConfig, auth util.GitAuth) error { | ||
gitInfoClient, err := NewGitInformationClient(v.GitInformationServiceURL, v.Token) | ||
if err != nil { | ||
return err | ||
} | ||
msg := " testing repository access 🔐" | ||
spinner, err := format.NewSpinner(msg, msg) | ||
if err != nil { | ||
return err | ||
} | ||
gitInfoClient.SetLogRetryFunc(retryRepoAccess(spinner, v.Debug)) | ||
if err := spinner.Start(); err != nil { | ||
return err | ||
} | ||
if err := testRepositoryAccess(ctx, gitInfoClient, git, auth); err != nil { | ||
if err := spinner.StopFail(); err != nil { | ||
return err | ||
} | ||
return err | ||
} | ||
return spinner.Stop() | ||
} | ||
|
||
// testRepositoryAccess tests if the given git repository can be accessed. | ||
func testRepositoryAccess(ctx context.Context, client *GitInformationClient, git *apps.ApplicationGitConfig, auth util.GitAuth) error { | ||
repoInfo, err := client.RepositoryInformation(ctx, git.URL, auth) | ||
if err != nil { | ||
// we are not returning a detailed error here as it might be | ||
// too technical. The full error can still be seen by using | ||
// a different RetryLog function in the client. | ||
return errors.New( | ||
"communication issue with git information service " + | ||
"(use --skip-repo-access-check to skip this check)", | ||
) | ||
} | ||
if len(repoInfo.Warnings) > 0 { | ||
fmt.Fprintf(os.Stderr, "warning: %s\n", strings.Join(repoInfo.Warnings, ".")) | ||
} | ||
if repoInfo.Error != "" { | ||
return errors.New(repoInfo.Error) | ||
} | ||
if !(containsBranch(git.Revision, repoInfo) || | ||
containsTag(git.Revision, repoInfo)) { | ||
return fmt.Errorf( | ||
"can not find specified git revision (%q) in repository", | ||
git.Revision, | ||
) | ||
} | ||
// it is possible to set a git URL without a proper scheme. In that | ||
// case, HTTPS is used as a default. If the access check succeeds we | ||
// need to overwrite the URL in the application as it will otherwise be | ||
// denied by the webhook. | ||
git.URL = repoInfo.RepositoryInfo.URL | ||
return nil | ||
} | ||
|
||
func retryRepoAccess(spinner *yacspin.Spinner, debug bool) func(err error) { | ||
return func(err error) { | ||
// in non debug mode we just change the color of the spinner to | ||
// indicate that something went wrong, but we are still on it | ||
if err := spinner.Colors("fgYellow"); err != nil { | ||
fmt.Fprintf(os.Stderr, "\nerror: %v\n", err) | ||
} | ||
if debug { | ||
fmt.Fprintf(os.Stderr, "\nerror: %v\n", err) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,192 @@ | ||
package validation | ||
|
||
import ( | ||
"bytes" | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"net/url" | ||
"os" | ||
"time" | ||
|
||
apps "github.com/ninech/apis/apps/v1alpha1" | ||
"github.com/ninech/nctl/api/util" | ||
"k8s.io/apimachinery/pkg/util/wait" | ||
"k8s.io/client-go/util/retry" | ||
) | ||
|
||
type GitInformationClient struct { | ||
token string | ||
url *url.URL | ||
client *http.Client | ||
logRetryFunc func(err error) | ||
retryBackoff wait.Backoff | ||
} | ||
|
||
// NewGitInformationClient returns a client which can be used to retrieve | ||
// metadata information about a given git repository | ||
func NewGitInformationClient(address string, token string) (*GitInformationClient, error) { | ||
u, err := url.Parse(address) | ||
if err != nil { | ||
return nil, fmt.Errorf("can not parse git information service URL: %w", err) | ||
} | ||
return defaultGitInformationClient(setURLDefaults(u), token), nil | ||
} | ||
|
||
func setURLDefaults(u *url.URL) *url.URL { | ||
// the git information service just responds to messages on /explore | ||
newURL := u.JoinPath("explore") | ||
if u.Scheme == "" { | ||
newURL.Scheme = "https" | ||
} | ||
return newURL | ||
} | ||
|
||
func defaultGitInformationClient(url *url.URL, token string) *GitInformationClient { | ||
g := &GitInformationClient{ | ||
token: token, | ||
url: url, | ||
client: http.DefaultClient, | ||
retryBackoff: wait.Backoff{ | ||
Steps: 5, | ||
Duration: 200 * time.Millisecond, | ||
Factor: 2.0, | ||
Jitter: 0.1, | ||
Cap: 2 * time.Second, | ||
}, | ||
} | ||
g.logRetryFunc = func(err error) { | ||
g.logError("Retrying because of error: %v\n", err) | ||
} | ||
return g | ||
} | ||
|
||
func (g *GitInformationClient) logError(format string, v ...any) { | ||
fmt.Fprintf(os.Stderr, format, v...) | ||
} | ||
|
||
// SetLogRetryFunc allows to set the function which logs retries when doing | ||
// requests to the git information service | ||
func (g *GitInformationClient) SetLogRetryFunc(f func(err error)) { | ||
g.logRetryFunc = f | ||
} | ||
|
||
// SetRetryBackoffs sets the backoff properties for retries | ||
func (g *GitInformationClient) SetRetryBackoffs(backoff wait.Backoff) { | ||
g.retryBackoff = backoff | ||
} | ||
|
||
func (g *GitInformationClient) repositoryInformation(ctx context.Context, url string, auth util.GitAuth) (*apps.GitExploreResponse, error) { | ||
req := apps.GitExploreRequest{ | ||
Repository: url, | ||
} | ||
if auth.Enabled() { | ||
req.Auth = &apps.Auth{} | ||
if auth.HasBasicAuth() { | ||
req.Auth.BasicAuth = &apps.BasicAuth{ | ||
Username: *auth.Username, | ||
Password: *auth.Password, | ||
} | ||
} | ||
if auth.HasPrivateKey() { | ||
req.Auth.PrivateKey = []byte(*auth.SSHPrivateKey) | ||
} | ||
} | ||
return g.sendRequest(ctx, req) | ||
} | ||
|
||
// RepositoryInformation returns information about a given git repository. It retries on client connection issues. | ||
func (g *GitInformationClient) RepositoryInformation(ctx context.Context, url string, auth util.GitAuth) (*apps.GitExploreResponse, error) { | ||
var repoInfo *apps.GitExploreResponse | ||
err := retry.OnError( | ||
g.retryBackoff, | ||
func(err error) bool { | ||
if g.logRetryFunc != nil { | ||
g.logRetryFunc(err) | ||
} | ||
// retry regardless of the error | ||
return true | ||
}, | ||
func() error { | ||
var err error | ||
repoInfo, err = g.repositoryInformation(ctx, url, auth) | ||
return err | ||
}) | ||
return repoInfo, err | ||
} | ||
|
||
func (g *GitInformationClient) sendRequest(ctx context.Context, req apps.GitExploreRequest) (*apps.GitExploreResponse, error) { | ||
data, err := json.Marshal(req) | ||
if err != nil { | ||
return nil, fmt.Errorf("can not JSON marshal request: %w", err) | ||
} | ||
|
||
httpReq, err := http.NewRequest(http.MethodPost, g.url.String(), bytes.NewReader(data)) | ||
if err != nil { | ||
return nil, fmt.Errorf("can not create HTTP request: %w", err) | ||
} | ||
if g.token != "" { | ||
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", g.token)) | ||
} | ||
|
||
resp, err := g.client.Do(httpReq.WithContext(ctx)) | ||
if err != nil { | ||
return nil, err | ||
} | ||
defer resp.Body.Close() | ||
body, err := io.ReadAll(resp.Body) | ||
if err != nil { | ||
return nil, fmt.Errorf("can not read response body: %w", err) | ||
} | ||
|
||
exploreResponse := &apps.GitExploreResponse{} | ||
if err := json.Unmarshal(body, exploreResponse); err != nil { | ||
return nil, fmt.Errorf( | ||
"can not unmarshal response %q with status code %d: %w", | ||
string(body), | ||
resp.StatusCode, | ||
err, | ||
) | ||
} | ||
return exploreResponse, nil | ||
} | ||
|
||
// containsTag returns true of the given tag exists in the git explore response | ||
func containsTag(tag string, response *apps.GitExploreResponse) bool { | ||
if response.RepositoryInfo == nil { | ||
return false | ||
} | ||
for _, item := range response.RepositoryInfo.Tags { | ||
if item == tag { | ||
return true | ||
} | ||
} | ||
return false | ||
} | ||
|
||
// containsBranch returns true of the given branch exists in the git explore response | ||
func containsBranch(branch string, response *apps.GitExploreResponse) bool { | ||
if response.RepositoryInfo == nil { | ||
return false | ||
} | ||
for _, item := range response.RepositoryInfo.Branches { | ||
if item == branch { | ||
return true | ||
} | ||
} | ||
return false | ||
} | ||
|
||
// RetryLogFunc returns a retry log function depending on the given showErrors | ||
// parameter. If it is set to true, exact errors are shown when retrying to | ||
// connect to the git information service. Otherwise they are not shown. | ||
func RetryLogFunc(showErrors bool) func(err error) { | ||
return func(err error) { | ||
if showErrors { | ||
fmt.Fprintf(os.Stderr, "got error: %v\n", err) | ||
} | ||
fmt.Fprintln(os.Stderr, "Retrying...") | ||
} | ||
} |
Oops, something went wrong.