Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

use git information service on app creation and updates #75

Merged
merged 1 commit into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading