Skip to content

Commit

Permalink
use git information service on app creation and updates
Browse files Browse the repository at this point in the history
This implements the usage of the git information service into `nctl`. It
allows to check if the specified git repository and revision can be
accessed/found.
  • Loading branch information
thirdeyenick committed Feb 2, 2024
1 parent 70d730a commit c4f2972
Show file tree
Hide file tree
Showing 10 changed files with 1,109 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
}
87 changes: 87 additions & 0 deletions api/validation/apps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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) {
spinner.Colors("fgYellow")

Check failure on line 82 in api/validation/apps.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `spinner.Colors` is not checked (errcheck)
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 c4f2972

Please sign in to comment.