1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 07:49:41 +02:00

feat(stacks): support automated sync for stacks [EE-248] (#5340)

This commit is contained in:
Dmitry Salakhov 2021-08-17 13:12:07 +12:00 committed by GitHub
parent 5fe90db36a
commit bcccdfb669
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
94 changed files with 2680 additions and 469 deletions

View file

@ -2,15 +2,17 @@ package git
import (
"context"
"encoding/json"
"fmt"
"github.com/pkg/errors"
"github.com/portainer/portainer/api/archive"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
"github.com/pkg/errors"
"github.com/portainer/portainer/api/archive"
)
const (
@ -37,7 +39,7 @@ type azureDownloader struct {
func NewAzureDownloader(client *http.Client) *azureDownloader {
return &azureDownloader{
client: client,
client: client,
baseUrl: "https://dev.azure.com",
}
}
@ -100,6 +102,57 @@ func (a *azureDownloader) downloadZipFromAzureDevOps(ctx context.Context, option
return zipFile.Name(), nil
}
func (a *azureDownloader) latestCommitID(ctx context.Context, options fetchOptions) (string, error) {
config, err := parseUrl(options.repositoryUrl)
if err != nil {
return "", errors.WithMessage(err, "failed to parse url")
}
refsUrl, err := a.buildRefsUrl(config, options.referenceName)
if err != nil {
return "", errors.WithMessage(err, "failed to build azure refs url")
}
req, err := http.NewRequestWithContext(ctx, "GET", refsUrl, nil)
if options.username != "" || options.password != "" {
req.SetBasicAuth(options.username, options.password)
} else if config.username != "" || config.password != "" {
req.SetBasicAuth(config.username, config.password)
}
if err != nil {
return "", errors.WithMessage(err, "failed to create a new HTTP request")
}
resp, err := a.client.Do(req)
if err != nil {
return "", errors.WithMessage(err, "failed to make an HTTP request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to get repository refs with a status \"%v\"", resp.Status)
}
var refs struct {
Value []struct {
Name string `json:"name"`
ObjectId string `json:"objectId"`
}
}
if err := json.NewDecoder(resp.Body).Decode(&refs); err != nil {
return "", errors.Wrap(err, "could not parse Azure Refs response")
}
for _, ref := range refs.Value {
if strings.EqualFold(ref.Name, options.referenceName) {
return ref.ObjectId, nil
}
}
return "", errors.Errorf("could not find ref %q in the repository", options.referenceName)
}
func parseUrl(rawUrl string) (*azureOptions, error) {
if strings.HasPrefix(rawUrl, "https://") || strings.HasPrefix(rawUrl, "http://") {
return parseHttpUrl(rawUrl)
@ -193,6 +246,27 @@ func (a *azureDownloader) buildDownloadUrl(config *azureOptions, referenceName s
return u.String(), nil
}
func (a *azureDownloader) buildRefsUrl(config *azureOptions, referenceName string) (string, error) {
rawUrl := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/refs",
a.baseUrl,
url.PathEscape(config.organisation),
url.PathEscape(config.project),
url.PathEscape(config.repository))
u, err := url.Parse(rawUrl)
if err != nil {
return "", errors.Wrapf(err, "failed to parse refs url path %s", rawUrl)
}
// filterContains=main&api-version=6.0
q := u.Query()
q.Set("filterContains", formatReferenceName(referenceName))
q.Set("api-version", "6.0")
u.RawQuery = q.Encode()
return u.String(), nil
}
const (
branchPrefix = "refs/heads/"
tagPrefix = "refs/tags/"

View file

@ -78,6 +78,18 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) {
assert.FileExists(t, filepath.Join(dst, "README.md"))
}
func TestService_LatestCommitID_Azure(t *testing.T) {
ensureIntegrationTest(t)
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
service := NewService()
repositoryUrl := "https://portainer.visualstudio.com/Playground/_git/dev_integration"
id, err := service.LatestCommitID(repositoryUrl, "refs/heads/main", "", pat)
assert.NoError(t, err)
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
}
func getRequiredValue(t *testing.T, name string) string {
value, ok := os.LookupEnv(name)
if !ok {

View file

@ -2,11 +2,12 @@ package git
import (
"context"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_buildDownloadUrl(t *testing.T) {
@ -27,6 +28,23 @@ func Test_buildDownloadUrl(t *testing.T) {
}
}
func Test_buildRefsUrl(t *testing.T) {
a := NewAzureDownloader(nil)
u, err := a.buildRefsUrl(&azureOptions{
organisation: "organisation",
project: "project",
repository: "repository",
}, "refs/heads/main")
expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/refs?filterContains=main&api-version=6.0")
actualUrl, _ := url.Parse(u)
assert.NoError(t, err)
assert.Equal(t, expectedUrl.Host, actualUrl.Host)
assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme)
assert.Equal(t, expectedUrl.Path, actualUrl.Path)
assert.Equal(t, expectedUrl.Query(), actualUrl.Query())
}
func Test_parseAzureUrl(t *testing.T) {
type args struct {
url string
@ -248,3 +266,110 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
})
}
}
func Test_azureDownloader_latestCommitID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := `{
"value": [
{
"name": "refs/heads/feature/calcApp",
"objectId": "ffe9cba521f00d7f60e322845072238635edb451",
"creator": {
"displayName": "Normal Paulk",
"url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"_links": {
"avatar": {
"href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
}
},
"id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"uniqueName": "dev@mailserver.com",
"imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
},
"url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Ffeature%2FcalcApp"
},
{
"name": "refs/heads/feature/replacer",
"objectId": "917131a709996c5cfe188c3b57e9a6ad90e8b85c",
"creator": {
"displayName": "Normal Paulk",
"url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"_links": {
"avatar": {
"href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
}
},
"id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"uniqueName": "dev@mailserver.com",
"imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
},
"url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Ffeature%2Freplacer"
},
{
"name": "refs/heads/master",
"objectId": "ffe9cba521f00d7f60e322845072238635edb451",
"creator": {
"displayName": "Normal Paulk",
"url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"_links": {
"avatar": {
"href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
}
},
"id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"uniqueName": "dev@mailserver.com",
"imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
},
"url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Fmaster"
}
],
"count": 3
}`
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(response))
}))
defer server.Close()
a := &azureDownloader{
client: server.Client(),
baseUrl: server.URL,
}
tests := []struct {
name string
args fetchOptions
want string
wantErr bool
}{
{
name: "should be able to parse response",
args: fetchOptions{
referenceName: "refs/heads/master",
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository"},
want: "ffe9cba521f00d7f60e322845072238635edb451",
wantErr: false,
},
{
name: "should be able to parse response",
args: fetchOptions{
referenceName: "refs/heads/unknown",
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository"},
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, err := a.latestCommitID(context.Background(), tt.args)
if (err != nil) != tt.wantErr {
t.Errorf("azureDownloader.latestCommitID() error = %v, wantErr %v", err, tt.wantErr)
return
}
assert.Equal(t, tt.want, id)
})
}
}

View file

@ -6,16 +6,26 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/pkg/errors"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/transport/client"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/storage/memory"
)
type fetchOptions struct {
repositoryUrl string
username string
password string
referenceName string
}
type cloneOptions struct {
repositoryUrl string
username string
@ -26,6 +36,7 @@ type cloneOptions struct {
type downloader interface {
download(ctx context.Context, dst string, opt cloneOptions) error
latestCommitID(ctx context.Context, opt fetchOptions) (string, error)
}
type gitClient struct {
@ -36,13 +47,7 @@ func (c gitClient) download(ctx context.Context, dst string, opt cloneOptions) e
gitOptions := git.CloneOptions{
URL: opt.repositoryUrl,
Depth: opt.depth,
}
if opt.password != "" || opt.username != "" {
gitOptions.Auth = &githttp.BasicAuth{
Username: opt.username,
Password: opt.password,
}
Auth: getAuth(opt.username, opt.password),
}
if opt.referenceName != "" {
@ -62,6 +67,44 @@ func (c gitClient) download(ctx context.Context, dst string, opt cloneOptions) e
return nil
}
func (c gitClient) latestCommitID(ctx context.Context, opt fetchOptions) (string, error) {
remote := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
Name: "origin",
URLs: []string{opt.repositoryUrl},
})
listOptions := &git.ListOptions{
Auth: getAuth(opt.username, opt.password),
}
refs, err := remote.List(listOptions)
if err != nil {
return "", errors.Wrap(err, "failed to list repository refs")
}
for _, ref := range refs {
if strings.EqualFold(ref.Name().String(), opt.referenceName) {
return ref.Hash().String(), nil
}
}
return "", errors.Errorf("could not find ref %q in the repository", opt.referenceName)
}
func getAuth(username, password string) *githttp.BasicAuth {
if password != "" {
if username == "" {
username = "token"
}
return &githttp.BasicAuth{
Username: username,
Password: password,
}
}
return nil
}
// Service represents a service for managing Git.
type Service struct {
httpsCli *http.Client
@ -109,3 +152,19 @@ func (service *Service) cloneRepository(destination string, options cloneOptions
return service.git.download(context.TODO(), destination, options)
}
// LatestCommitID returns SHA1 of the latest commit of the specified reference
func (service *Service) LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) {
options := fetchOptions{
repositoryUrl: repositoryURL,
username: username,
password: password,
referenceName: referenceName,
}
if isAzureUrl(options.repositoryUrl) {
return service.azure.latestCommitID(context.TODO(), options)
}
return service.git.latestCommitID(context.TODO(), options)
}

View file

@ -12,7 +12,7 @@ import (
func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
ensureIntegrationTest(t)
pat := getRequiredValue(t, "GITHUB_PAT")
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
service := NewService()
@ -21,7 +21,20 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
defer os.RemoveAll(dst)
repositoryUrl := "https://github.com/portainer/private-test-repository.git"
err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, pat)
err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, accessToken)
assert.NoError(t, err)
assert.FileExists(t, filepath.Join(dst, "README.md"))
}
func TestService_LatestCommitID_GitHub(t *testing.T) {
ensureIntegrationTest(t)
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
service := NewService()
repositoryUrl := "https://github.com/portainer/private-test-repository.git"
id, err := service.LatestCommitID(repositoryUrl, "refs/heads/main", username, accessToken)
assert.NoError(t, err)
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
}

View file

@ -105,7 +105,19 @@ func Test_cloneRepository(t *testing.T) {
})
assert.NoError(t, err)
assert.Equal(t, 3, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
assert.Equal(t, 4, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
}
func Test_latestCommitID(t *testing.T) {
service := Service{git: gitClient{preserveGitDirectory: true}} // no need for http client since the test access the repo via file system.
repositoryURL := bareRepoDir
referenceName := "refs/heads/main"
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "")
assert.NoError(t, err)
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
}
func getCommitHistoryLength(t *testing.T, err error, dir string) int {
@ -137,6 +149,10 @@ func (t *testDownloader) download(_ context.Context, _ string, _ cloneOptions) e
return nil
}
func (t *testDownloader) latestCommitID(_ context.Context, _ fetchOptions) (string, error) {
return "", nil
}
func Test_cloneRepository_azure(t *testing.T) {
tests := []struct {
name string

BIN
api/git/testdata/azure-repo copy.zip vendored Normal file

Binary file not shown.

Binary file not shown.

View file

@ -1,10 +1,20 @@
package gittypes
// RepoConfig represents a configuration for a repo
type RepoConfig struct {
// The repo url
URL string `example:"https://github.com/portainer/portainer-ee.git"`
URL string `example:"https://github.com/portainer/portainer.git"`
// The reference name
ReferenceName string `example:"refs/heads/branch_name"`
// Path to where the config file is in this url/refName
ConfigFilePath string `example:"docker-compose.yml"`
// Git credentials
Authentication *GitAuthentication
// Repository hash
ConfigHash string `example:"bc4c183d756879ea4d173315338110b31004b8e0"`
}
type GitAuthentication struct {
Username string
Password string
}