Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion cmd/pipecd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ func (s *server) run(ctx context.Context, input cli.Input) error {
datastore.NewPipedStore(ds),
input.Logger,
)
service = grpcapi.NewPipedAPI(ctx, ds, sls, alss, las, cmds, statCache, cmdOutputStore, cfg.Address, input.Logger)
service = grpcapi.NewPipedAPI(ctx, ds, sls, alss, las, cmds, statCache, rd, cmdOutputStore, cfg.Address, input.Logger)
opts = []rpc.Option{
rpc.WithPort(s.pipedAPIPort),
rpc.WithGracePeriod(s.gracePeriod),
Expand Down
5 changes: 5 additions & 0 deletions pkg/app/api/grpcapi/grpcapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"context"
"encoding/base64"
"errors"
"fmt"

"go.uber.org/zap"
"google.golang.org/grpc/codes"
Expand Down Expand Up @@ -189,3 +190,7 @@ func getEncriptionKey(se *model.Piped_SecretEncryption) ([]byte, error) {
return nil, status.Error(codes.FailedPrecondition, "The piped does not contain a valid encryption type")
}
}

func makeUnregisteredAppsCacheKey(projectID string) string {
return fmt.Sprintf("HASHKEY:UNREGISTERED_APPS:%s", projectID)
}
21 changes: 18 additions & 3 deletions pkg/app/api/grpcapi/piped_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ import (
"github.com/pipe-cd/pipe/pkg/app/api/stagelogstore"
"github.com/pipe-cd/pipe/pkg/cache"
"github.com/pipe-cd/pipe/pkg/cache/memorycache"
"github.com/pipe-cd/pipe/pkg/cache/rediscache"
"github.com/pipe-cd/pipe/pkg/datastore"
"github.com/pipe-cd/pipe/pkg/filestore"
"github.com/pipe-cd/pipe/pkg/model"
"github.com/pipe-cd/pipe/pkg/redis"
"github.com/pipe-cd/pipe/pkg/rpc/rpcauth"
)

Expand All @@ -58,13 +60,14 @@ type PipedAPI struct {
deploymentPipedCache cache.Cache
envProjectCache cache.Cache
pipedStatCache cache.Cache
redis redis.Redis

webBaseURL string
logger *zap.Logger
}

// NewPipedAPI creates a new PipedAPI instance.
func NewPipedAPI(ctx context.Context, ds datastore.DataStore, sls stagelogstore.Store, alss applicationlivestatestore.Store, las analysisresultstore.Store, cs commandstore.Store, hc cache.Cache, cop commandOutputPutter, webBaseURL string, logger *zap.Logger) *PipedAPI {
func NewPipedAPI(ctx context.Context, ds datastore.DataStore, sls stagelogstore.Store, alss applicationlivestatestore.Store, las analysisresultstore.Store, cs commandstore.Store, hc cache.Cache, rd redis.Redis, cop commandOutputPutter, webBaseURL string, logger *zap.Logger) *PipedAPI {
a := &PipedAPI{
applicationStore: datastore.NewApplicationStore(ds),
deploymentStore: datastore.NewDeploymentStore(ds),
Expand All @@ -82,6 +85,7 @@ func NewPipedAPI(ctx context.Context, ds datastore.DataStore, sls stagelogstore.
deploymentPipedCache: memorycache.NewTTLCache(ctx, 24*time.Hour, 3*time.Hour),
envProjectCache: memorycache.NewTTLCache(ctx, 24*time.Hour, 3*time.Hour),
pipedStatCache: hc,
redis: rd,

@khanhtc1202 khanhtc1202 Nov 29, 2021

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we use the redis explicitly here or just pass pipedCfgCache as we do for pipedStatCache? 🤔
Since, for instance, we may want to support not just redis but memcached as shared cache in the future, should keep all of those specified things out of our logic.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel you. Honestly I also want to abstract it in such a way. But we want to generate HashCache with different keys for each project in the method, that's why PipedAPI needs to have a connection to the redis server.

https://github.com/pipe-cd/pipe/pull/2847/files#diff-dc8d28c3b055fcad0f022406b744ce756e3be8c630b6c40aef12e026c6a58dcfR1014

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah okay I see, get the point here, thanks 👍

@khanhtc1202 khanhtc1202 Nov 30, 2021

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just created this issue for those things, lets think about it later 👍
#2865

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 👍

webBaseURL: webBaseURL,
logger: logger.Named("piped-api"),
}
Expand Down Expand Up @@ -994,8 +998,19 @@ func (a *PipedAPI) UpdateApplicationConfigurations(ctx context.Context, req *pip
}

func (a *PipedAPI) ReportUnregisteredApplicationConfigurations(ctx context.Context, req *pipedservice.ReportUnregisteredApplicationConfigurationsRequest) (*pipedservice.ReportUnregisteredApplicationConfigurationsResponse, error) {
// TODO: Make the unused application configurations cache up-to-date
return nil, status.Errorf(codes.Unimplemented, "ReportUnregisteredApplicationConfigurations is not implemented yet")
projectID, pipedID, _, err := rpcauth.ExtractPipedToken(ctx)
if err != nil {
return nil, err
}

key := makeUnregisteredAppsCacheKey(projectID)
c := rediscache.NewHashCache(a.redis, key)
// Cache a slice of *model.ApplicationInfo.
if err := c.Put(pipedID, req.Applications); err != nil {
return nil, status.Error(codes.Internal, "failed to put the unregistered apps to the cache")
}

return &pipedservice.ReportUnregisteredApplicationConfigurationsResponse{}, nil
Comment on lines +1001 to +1019

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice update.

}

// CreateDeploymentChain creates a new deployment chain object and all required commands to
Expand Down
82 changes: 82 additions & 0 deletions pkg/app/api/grpcapi/web_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -66,6 +67,7 @@ type WebAPI struct {
pipedProjectCache cache.Cache
envProjectCache cache.Cache
insightCache cache.Cache
redis redis.Redis

projectsInConfig map[string]config.ControlPlaneProject
logger *zap.Logger
Expand Down Expand Up @@ -102,6 +104,7 @@ func NewWebAPI(
pipedProjectCache: memorycache.NewTTLCache(ctx, 24*time.Hour, 3*time.Hour),
envProjectCache: memorycache.NewTTLCache(ctx, 24*time.Hour, 3*time.Hour),
insightCache: rediscache.NewTTLCache(rd, 3*time.Hour),
redis: rd,
logger: logger.Named("web-api"),
}
return a
Expand Down Expand Up @@ -599,6 +602,85 @@ func (a *WebAPI) validatePipedBelongsToProject(ctx context.Context, pipedID, pro
return nil
}

func (a *WebAPI) ListUnregisteredApplications(ctx context.Context, _ *webservice.ListUnregisteredApplicationsRequest) (*webservice.ListUnregisteredApplicationsResponse, error) {
claims, err := rpcauth.ExtractClaims(ctx)
if err != nil {
a.logger.Error("failed to authenticate the current user", zap.Error(err))
return nil, err
}

// Collect all apps that belong to the project.
key := makeUnregisteredAppsCacheKey(claims.Role.ProjectId)
c := rediscache.NewHashCache(a.redis, key)
// pipedToApps assumes to be a map["piped-id"][]*model.ApplicationInfo

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The returned data will not be decoded to this *model.ApplicationInfo automatically to be ready to use since Redis doesn't have any way to know how the data was formatted. It just stored binary data.
So I think we have to encode (e.g. by JSON) the list before storing into Redis and decode them after getting back from Redis.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seriously? I am sorry, I had neglected to do my research. I assumed that redigo encodes/decodes using some kind of ways (such as gob). Let me look into how redigo encodes and re-apply new encoding way for us.

pipedToApps, err := c.GetAll()
if errors.Is(err, cache.ErrNotFound) {
return &webservice.ListUnregisteredApplicationsResponse{}, nil
}
if err != nil {
a.logger.Error("failed to get unregistered apps", zap.Error(err))
return nil, status.Error(codes.Internal, "Failed to get unregistered apps")
}

// Integrate all apps cached for each Piped.
allApps := make([]*model.ApplicationInfo, 0)
for _, as := range pipedToApps {
apps, ok := as.([]*model.ApplicationInfo)
if !ok {
return nil, status.Error(codes.Internal, "Unexpected data cached")
}
allApps = append(allApps, apps...)
}

return &webservice.ListUnregisteredApplicationsResponse{
Repos: groupAppsByRepo(allApps),
}, nil
}

func groupAppsByRepo(apps []*model.ApplicationInfo) []*webservice.ListUnregisteredApplicationsResponse_Repo {
if len(apps) == 0 {
return []*webservice.ListUnregisteredApplicationsResponse_Repo{}
}
if len(apps) == 1 {
return []*webservice.ListUnregisteredApplicationsResponse_Repo{
{Id: apps[0].RepoId, Apps: apps},
}
}

// Make a map from repo-id to apps.
repoToApps := make(map[string][]*model.ApplicationInfo)
for _, app := range apps {
if _, ok := repoToApps[app.RepoId]; !ok {
repoToApps[app.RepoId] = []*model.ApplicationInfo{}
}
repoToApps[app.RepoId] = append(repoToApps[app.RepoId], app)
}

// Tidy apps.
repos := make([]*webservice.ListUnregisteredApplicationsResponse_Repo, 0, len(repoToApps))
for repoID, as := range repoToApps {
// Eliminate duplicated apps
tidiedApps := make([]*model.ApplicationInfo, 0, len(as))
gitPaths := make(map[string]struct{})
for _, app := range as {
if _, ok := gitPaths[app.GetPath()]; ok {
continue
}
gitPaths[app.GetPath()] = struct{}{}
tidiedApps = append(tidiedApps, app)
}

sort.Slice(tidiedApps, func(i, j int) bool {
return tidiedApps[i].GetPath() < tidiedApps[j].GetPath()
})
repos = append(repos, &webservice.ListUnregisteredApplicationsResponse_Repo{
Id: repoID,
Apps: tidiedApps,
})
}
return repos

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some concerns around the way we are grouping applications by repo-id and eliminating duplicates by GitPath.

Since we don't have any guide or restriction on the repository ID across Piped currently, so users can use the same repo ID in different Pipeds for different repositories.

For example:

  • piped-dev has a manifests repo-id which is referring to manifests-dev repository
  • piped-prod also has a manifests repo-id which is referring to manifests-prod repository

That will result in a not-correct list.

So I think we can return the raw data from the cache that includes PipedID in the response.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For sure. Obviously repo-id isn't a project-wide concept. Apparently It's enough to give back directly grouped by Piped.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response structure depends on how to show the list on the adding form. Of course it will be able to change, but tell me a little bit more about your thoughts.

I'm guessing the unregistered list on the web is going to be kind of like:

  • piped-1
    • app-1 (repo: "repo-1", path: "path/to", cfgFilename: ".pipe.yaml")
    • app-2 (repo: "repo-2", path: "path/to", cfgFilename: ".pipe.yaml")
  • piped-2
    • ...

Therefore, the response of ListUnregisteredApplications would be like:

message ListUnregisteredApplicationsResponse {
    message Piped {
        string id = 1;
        repeated model.ApplicationInfo apps = 2;
    }
    repeated Piped pipeds = 1;
}

But if it's enough to show a flat list like:

  • app-1 (piped: "piped-1", repo: "repo-1", path: "path/to", cfgFilename: ".pipe.yaml")
  • app-2 (piped: "piped-2", repo: "repo-1", path: "path/to", cfgFilename: ".pipe.yaml")

just add a field called PipedId to ApplicationInfo and return a list of ApplicationInfo.

}

// TODO: Validate the specified piped to ensure that it belongs to the specified environment.
func (a *WebAPI) AddApplication(ctx context.Context, req *webservice.AddApplicationRequest) (*webservice.AddApplicationResponse, error) {
claims, err := rpcauth.ExtractClaims(ctx)
Expand Down
186 changes: 186 additions & 0 deletions pkg/app/api/grpcapi/web_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"

"github.com/pipe-cd/pipe/pkg/app/api/service/webservice"
"github.com/pipe-cd/pipe/pkg/cache"
"github.com/pipe-cd/pipe/pkg/cache/cachetest"
"github.com/pipe-cd/pipe/pkg/datastore"
Expand Down Expand Up @@ -374,3 +375,188 @@ func TestValidateApprover(t *testing.T) {
})
}
}

func Test_groupAppsByRepo(t *testing.T) {
testcases := []struct {
name string
apps []*model.ApplicationInfo
want []*webservice.ListUnregisteredApplicationsResponse_Repo
}{
{
name: "zero app given",
apps: []*model.ApplicationInfo{},
want: []*webservice.ListUnregisteredApplicationsResponse_Repo{},
},
{
name: "one app given",
apps: []*model.ApplicationInfo{
{
Name: "app1",
RepoId: "repo1",
Path: "path/to/app1",
},
},
want: []*webservice.ListUnregisteredApplicationsResponse_Repo{
{
Id: "repo1",
Apps: []*model.ApplicationInfo{
{
Name: "app1",
RepoId: "repo1",
Path: "path/to/app1",
},
},
},
},
},
{
name: "apps within the same repo",
apps: []*model.ApplicationInfo{
{
Name: "app1",
RepoId: "repo1",
Path: "path/to/app1",
},
{
Name: "app2",
RepoId: "repo1",
Path: "path/to/app2",
},
},
want: []*webservice.ListUnregisteredApplicationsResponse_Repo{
{
Id: "repo1",
Apps: []*model.ApplicationInfo{
{
Name: "app1",
RepoId: "repo1",
Path: "path/to/app1",
},
{
Name: "app2",
RepoId: "repo1",
Path: "path/to/app2",
},
},
},
},
},
{
name: "duplicated apps",
apps: []*model.ApplicationInfo{
{
Name: "app1",
RepoId: "repo1",
Path: "path/to/app1",
},
{
Name: "app1",
RepoId: "repo1",
Path: "path/to/app1",
},
{
Name: "app1",
RepoId: "repo1",
Path: "path/to/app1",
},
},
want: []*webservice.ListUnregisteredApplicationsResponse_Repo{
{
Id: "repo1",
Apps: []*model.ApplicationInfo{
{
Name: "app1",
RepoId: "repo1",
Path: "path/to/app1",
},
},
},
},
},
{
name: "apps across different repos",
apps: []*model.ApplicationInfo{
{
Name: "app1",
RepoId: "repo1",
Path: "path/to/app1",
},
{
Name: "app2",
RepoId: "repo2",
Path: "path/to/app2",
},
},
want: []*webservice.ListUnregisteredApplicationsResponse_Repo{
{
Id: "repo1",
Apps: []*model.ApplicationInfo{
{
Name: "app1",
RepoId: "repo1",
Path: "path/to/app1",
},
},
},
{
Id: "repo2",
Apps: []*model.ApplicationInfo{
{
Name: "app2",
RepoId: "repo2",
Path: "path/to/app2",
},
},
},
},
},
{
name: "out of order apps",
apps: []*model.ApplicationInfo{
{
Name: "app3",
RepoId: "repo1",
Path: "path/to/app3",
},
{
Name: "app1",
RepoId: "repo1",
Path: "path/to/app1",
},
{
Name: "app2",
RepoId: "repo1",
Path: "path/to/app2",
},
},
want: []*webservice.ListUnregisteredApplicationsResponse_Repo{
{
Id: "repo1",
Apps: []*model.ApplicationInfo{
{
Name: "app1",
RepoId: "repo1",
Path: "path/to/app1",
},
{
Name: "app2",
RepoId: "repo1",
Path: "path/to/app2",
},
{
Name: "app3",
RepoId: "repo1",
Path: "path/to/app3",
},
},
},
},
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
got := groupAppsByRepo(tc.apps)
assert.Equal(t, tc.want, got)
})
}
}
Loading