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 1, 2024
1 parent 70d730a commit 05ecfef
Show file tree
Hide file tree
Showing 9 changed files with 1,156 additions and 186 deletions.
64 changes: 62 additions & 2 deletions api/util/apps.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package util

import (
"context"
"encoding/pem"
"fmt"
"os"
"sort"
"strings"

apps "github.com/ninech/apis/apps/v1alpha1"
"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
Expand Down Expand Up @@ -128,6 +132,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 @@ -171,8 +183,8 @@ func (git GitAuth) UpdateSecret(secret *corev1.Secret) {
}

func (git GitAuth) Enabled() bool {
if git.Username != nil ||
git.Password != nil ||
if (git.Username != nil &&
git.Password != nil) ||
git.SSHPrivateKey != nil {
return true
}
Expand Down Expand Up @@ -229,3 +241,51 @@ 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
}

// TestRepositoryAccess tests if the given git repository can be accessed.
func TestRepositoryAccess(ctx context.Context, client *GitInformationClient, git *apps.ApplicationGitConfig, auth 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
}
211 changes: 211 additions & 0 deletions api/util/git_information_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package util

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"

apps "github.com/ninech/apis/apps/v1alpha1"
"github.com/pkg/errors"
)

const (
defaultTimeout = 15 * time.Second

Check failure on line 19 in api/util/git_information_client.go

View workflow job for this annotation

GitHub Actions / lint

const `defaultTimeout` is unused (unused)
defaultRetries = 2

Check failure on line 20 in api/util/git_information_client.go

View workflow job for this annotation

GitHub Actions / lint

const `defaultRetries` is unused (unused)
)

type GitInformationClient struct {
token string
url *url.URL
explorePath string

Check failure on line 26 in api/util/git_information_client.go

View workflow job for this annotation

GitHub Actions / lint

field `explorePath` is unused (unused)
client *http.Client
logRetryFunc func(retry int, maxRetries int, err error)
retryBackoffs []time.Duration
}

// 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 {
newURL := u.JoinPath("explore")
if u.Scheme == "" {
newURL.Scheme = "https"
}
return newURL
}

func defaultGitInformationClient(url *url.URL, token string) *GitInformationClient {
return &GitInformationClient{
token: token,
url: url,
client: http.DefaultClient,
retryBackoffs: []time.Duration{time.Second, 2 * time.Second},
}
}

func (g *GitInformationClient) logError(format string, v ...any) {
fmt.Fprintf(os.Stderr, format, v...)
}

func (g *GitInformationClient) logRetry(retry int, maxRetry int, err error) {
if g.logRetryFunc != nil {
g.logRetryFunc(retry, maxRetry, err)
return
}
if retry < maxRetry {
g.logError("Retrying because of error: %v\n", err)
}
}

// SetLogRetryFunc allows to set the function which logs retries when doing
// requests to the git information service
func (g *GitInformationClient) SetLogRetryFunc(f func(retry int, maxRetries int, err error)) {
g.logRetryFunc = f
}

// SetRetryBackoffs sets the durations which the client will wait between retries
func (g *GitInformationClient) SetRetryBackoffs(backoffs []time.Duration) {
g.retryBackoffs = backoffs
}

func (g *GitInformationClient) repositoryInformation(ctx context.Context, url string, auth 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 GitAuth) (*apps.GitExploreResponse, error) {
var repoInfo *apps.GitExploreResponse
var err error
repoInfo, err = g.repositoryInformation(ctx, url, auth)
if err == nil {
return repoInfo, err
}
g.logRetry(0, len(g.retryBackoffs), err)
for retry, waitTime := range g.retryBackoffs {
time.Sleep(waitTime)
repoInfo, err = g.repositoryInformation(ctx, url, auth)
if err == nil {
break
}
g.logRetry(retry+1, len(g.retryBackoffs), 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("POST", 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))
}
httpReq = httpReq.WithContext(ctx)

resp, err := g.client.Do(httpReq)
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 := unmarshalResponse(bytes.NewReader(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
}

func unmarshalResponse(data io.Reader, response *apps.GitExploreResponse) error {
decoder := json.NewDecoder(data)
err := decoder.Decode(response)
if err != nil {
return err
}
_, err = decoder.Token()
if err == nil || !errors.Is(err, io.EOF) {
return errors.New("invalid data after top-level JSON value")
}
return 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 debug
// 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(retry int, maxRetries int, err error) {
return func(retry int, maxRetries int, err error) {
if showErrors {
fmt.Fprintf(os.Stderr, "got error: %v\n", err)
}
if retry < maxRetries {
fmt.Fprintln(os.Stderr, "Retrying...")
}
}
}
Loading

0 comments on commit 05ecfef

Please sign in to comment.