Skip to content

Commit

Permalink
Merge pull request #75 from ninech/add-initial-repo-check
Browse files Browse the repository at this point in the history
use git information service on app creation and updates
  • Loading branch information
thirdeyenick authored Feb 5, 2024
2 parents 70d730a + 2707a7b commit 44df2a1
Show file tree
Hide file tree
Showing 10 changed files with 1,113 additions and 191 deletions.
34 changes: 27 additions & 7 deletions api/util/apps.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package util

import (
"encoding/pem"
"fmt"
"sort"
"strings"
Expand Down Expand Up @@ -128,6 +129,14 @@ type GitAuth struct {
SSHPrivateKey *string
}

func (git GitAuth) HasPrivateKey() bool {
return git.SSHPrivateKey != nil
}

func (git GitAuth) HasBasicAuth() bool {
return git.Username != nil && git.Password != nil
}

func (git GitAuth) Secret(app *apps.Application) *corev1.Secret {
data := map[string][]byte{}

Expand Down Expand Up @@ -170,16 +179,12 @@ func (git GitAuth) UpdateSecret(secret *corev1.Secret) {
secret.Annotations[ManagedByAnnotation] = NctlName
}

// Enabled returns true if any kind of credentials are set in the GitAuth
func (git GitAuth) Enabled() bool {
if git.Username != nil ||
git.Password != nil ||
git.SSHPrivateKey != nil {
return true
}

return false
return git.HasBasicAuth() || git.HasPrivateKey()
}

// Valid validates the credentials in the GitAuth
func (git GitAuth) Valid() error {
if git.SSHPrivateKey != nil {
if *git.SSHPrivateKey == "" {
Expand Down Expand Up @@ -229,3 +234,18 @@ func GatherDNSDetails(items []apps.Application) []DNSDetail {
}
return result
}

// ValidatePEM validates if the passed content is in valid PEM format, errors
// out if the content is empty
func ValidatePEM(content string) (*string, error) {
if content == "" {
return nil, fmt.Errorf("the SSH private key cannot be empty")
}

content = strings.TrimSpace(content)
b, rest := pem.Decode([]byte(content))
if b == nil || len(rest) > 0 {
return nil, fmt.Errorf("no valid PEM formatted data found")
}
return &content, nil
}
91 changes: 91 additions & 0 deletions api/validation/apps.go
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)
}
}
}
192 changes: 192 additions & 0 deletions api/validation/git_information_client.go
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...")
}
}
Loading

0 comments on commit 44df2a1

Please sign in to comment.