diff --git a/assets/builtin-policy.csv b/assets/builtin-policy.csv index ee79de1decb77..9c38bddee0979 100644 --- a/assets/builtin-policy.csv +++ b/assets/builtin-policy.csv @@ -14,6 +14,7 @@ p, role:readonly, projects, get, *, allow p, role:readonly, accounts, get, *, allow p, role:readonly, gpgkeys, get, *, allow p, role:readonly, logs, get, */*, allow +p, role:readonly, exec, get, */*, allow p, role:admin, applications, create, */*, allow p, role:admin, applications, update, */*, allow diff --git a/cmd/argocd/commands/admin/settings_rbac.go b/cmd/argocd/commands/admin/settings_rbac.go index f56b35e825a1a..164acf7a53eed 100644 --- a/cmd/argocd/commands/admin/settings_rbac.go +++ b/cmd/argocd/commands/admin/settings_rbac.go @@ -35,6 +35,7 @@ var resourceMap map[string]string = map[string]string{ "key": rbacpolicy.ResourceGPGKeys, "log": rbacpolicy.ResourceLogs, "logs": rbacpolicy.ResourceLogs, + "exec": rbacpolicy.ResourceExec, "proj": rbacpolicy.ResourceProjects, "projs": rbacpolicy.ResourceProjects, "project": rbacpolicy.ResourceProjects, @@ -51,6 +52,7 @@ var validRBACResources map[string]bool = map[string]bool{ rbacpolicy.ResourceClusters: true, rbacpolicy.ResourceGPGKeys: true, rbacpolicy.ResourceLogs: true, + rbacpolicy.ResourceExec: true, rbacpolicy.ResourceProjects: true, rbacpolicy.ResourceRepositories: true, } diff --git a/cmd/argocd/commands/admin/testdata/rbac/policy.csv b/cmd/argocd/commands/admin/testdata/rbac/policy.csv index 966d38cfb097f..69a96b57db740 100644 --- a/cmd/argocd/commands/admin/testdata/rbac/policy.csv +++ b/cmd/argocd/commands/admin/testdata/rbac/policy.csv @@ -7,4 +7,5 @@ p, role:user, applications, delete, *, allow p, role:user, applications, delete, */guestbook, deny p, role:test, certificates, get, *, allow p, role:test, logs, get, */*, allow +p, role:test, exec, get, */*, allow g, test, role:user diff --git a/docs/operator-manual/rbac.md b/docs/operator-manual/rbac.md index e29dc52fc7a8d..5fb54bc7d40f4 100644 --- a/docs/operator-manual/rbac.md +++ b/docs/operator-manual/rbac.md @@ -28,7 +28,7 @@ Breaking down the permissions definition differs slightly between applications a ### RBAC Resources and Actions -Resources: `clusters`, `projects`, `applications`, `repositories`, `certificates`, `accounts`, `gpgkeys`, `logs` +Resources: `clusters`, `projects`, `applications`, `repositories`, `certificates`, `accounts`, `gpgkeys`, `logs`, `exec` Actions: `get`, `create`, `update`, `delete`, `sync`, `override`, `action` @@ -57,6 +57,7 @@ data: p, role:org-admin, repositories, update, *, allow p, role:org-admin, repositories, delete, *, allow p, role:org-admin, logs, get, *, allow + p, role:org-admin, exec, get, *, allow g, your-github-org:your-team, role:org-admin ``` @@ -72,11 +73,12 @@ p, role:staging-db-admins, applications, override, staging-db-admins/*, allow p, role:staging-db-admins, applications, sync, staging-db-admins/*, allow p, role:staging-db-admins, applications, update, staging-db-admins/*, allow p, role:staging-db-admins, logs, get, staging-db-admins/*, allow +p, role:staging-db-admins, exec, get, staging-db-admins/*, allow p, role:staging-db-admins, projects, get, staging-db-admins, allow g, db-admins, role:staging-db-admins ``` -This example defines a *role* called `staging-db-admins` with *eight permissions* that allow that role to perform the *actions* (`create`/`delete`/`get`/`override`/`sync`/`update` applications, `get` logs and `get` appprojects) against `*` (all) objects in the `staging-db-admins` Argo CD AppProject. +This example defines a *role* called `staging-db-admins` with *eight permissions* that allow that role to perform the *actions* (`create`/`delete`/`get`/`override`/`sync`/`update` applications, `get` logs, `get` exec and `get` appprojects) against `*` (all) objects in the `staging-db-admins` Argo CD AppProject. ## Anonymous Access diff --git a/docs/user-guide/commands/argocd_account_can-i.md b/docs/user-guide/commands/argocd_account_can-i.md index c32dc975138ea..71557b81681de 100644 --- a/docs/user-guide/commands/argocd_account_can-i.md +++ b/docs/user-guide/commands/argocd_account_can-i.md @@ -20,7 +20,7 @@ argocd account can-i update projects 'default' argocd account can-i create clusters '*' Actions: [get create update delete sync override] -Resources: [clusters projects applications repositories certificates logs] +Resources: [clusters projects applications repositories certificates logs exec] ``` diff --git a/go.mod b/go.mod index 5555d50ef242a..480a5867a21b8 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.1.2 github.com/gorilla/handlers v1.5.1 + github.com/gorilla/websocket v1.4.2 github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 @@ -153,7 +154,6 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.1.0 // indirect github.com/googleapis/gnostic v0.5.5 // indirect - github.com/gorilla/websocket v1.4.2 // indirect github.com/gregdel/pushover v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-version v1.2.1 // indirect diff --git a/server/application/application.go b/server/application/application.go index b6384f060623f..48ab4642472f8 100644 --- a/server/application/application.go +++ b/server/application/application.go @@ -44,6 +44,7 @@ import ( "github.com/argoproj/argo-cd/v2/reposerver/apiclient" servercache "github.com/argoproj/argo-cd/v2/server/cache" "github.com/argoproj/argo-cd/v2/server/rbacpolicy" + apputil "github.com/argoproj/argo-cd/v2/util/app" "github.com/argoproj/argo-cd/v2/util/argo" argoutil "github.com/argoproj/argo-cd/v2/util/argo" "github.com/argoproj/argo-cd/v2/util/db" @@ -57,6 +58,8 @@ import ( "github.com/argoproj/argo-cd/v2/util/settings" ) +type AppResourceTreeFn func(ctx context.Context, app *appv1.Application) (*appv1.ApplicationTree, error) + const ( maxPodLogsToRender = 10 backgroundPropagationPolicy string = "background" @@ -101,10 +104,10 @@ func NewServer( projectLock sync.KeyLock, settingsMgr *settings.SettingsManager, projInformer cache.SharedIndexInformer, -) application.ApplicationServiceServer { +) (application.ApplicationServiceServer, AppResourceTreeFn) { appBroadcaster := &broadcasterHandler{} appInformer.AddEventHandler(appBroadcaster) - return &Server{ + s := &Server{ ns: namespace, appclientset: appclientset, appLister: appLister, @@ -121,11 +124,7 @@ func NewServer( settingsMgr: settingsMgr, projInformer: projInformer, } -} - -// appRBACName formats fully qualified application name for RBAC check -func appRBACName(app appv1.Application) string { - return fmt.Sprintf("%s/%s", app.Spec.GetProject(), app.Name) + return s, s.GetAppResources } // List returns list of applications @@ -140,7 +139,7 @@ func (s *Server) List(ctx context.Context, q *application.ApplicationQuery) (*ap } newItems := make([]appv1.Application, 0) for _, a := range apps { - if s.enf.Enforce(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName(*a)) { + if s.enf.Enforce(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, apputil.AppRBACName(*a)) { newItems = append(newItems, *a) } } @@ -176,7 +175,7 @@ func (s *Server) Create(ctx context.Context, q *application.ApplicationCreateReq if q.GetApplication() == nil { return nil, fmt.Errorf("error creating application: application is nil in request") } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionCreate, appRBACName(*q.Application)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionCreate, apputil.AppRBACName(*q.Application)); err != nil { return nil, err } @@ -217,7 +216,7 @@ func (s *Server) Create(ctx context.Context, q *application.ApplicationCreateReq if q.Upsert == nil || !*q.Upsert { return nil, status.Errorf(codes.InvalidArgument, "existing application spec is different, use upsert flag to force update") } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionUpdate, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionUpdate, apputil.AppRBACName(*a)); err != nil { return nil, err } updated, err := s.updateApp(existing, a, ctx, true) @@ -296,7 +295,7 @@ func (s *Server) GetManifests(ctx context.Context, q *application.ApplicationMan if err != nil { return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, apputil.AppRBACName(*a)); err != nil { return nil, err } @@ -389,7 +388,7 @@ func (s *Server) Get(ctx context.Context, q *application.ApplicationQuery) (*app if err != nil { return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, apputil.AppRBACName(*a)); err != nil { return nil, err } if q.Refresh == nil { @@ -471,7 +470,7 @@ func (s *Server) ListResourceEvents(ctx context.Context, q *application.Applicat if err != nil { return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, apputil.AppRBACName(*a)); err != nil { return nil, err } var ( @@ -491,7 +490,7 @@ func (s *Server) ListResourceEvents(ctx context.Context, q *application.Applicat "involvedObject.namespace": a.Namespace, }).String() } else { - tree, err := s.getAppResources(ctx, a) + tree, err := s.GetAppResources(ctx, a) if err != nil { return nil, err } @@ -623,7 +622,7 @@ func (s *Server) updateApp(app *appv1.Application, newApp *appv1.Application, ct // Update updates an application func (s *Server) Update(ctx context.Context, q *application.ApplicationUpdateRequest) (*appv1.Application, error) { - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionUpdate, appRBACName(*q.Application)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionUpdate, apputil.AppRBACName(*q.Application)); err != nil { return nil, err } @@ -643,7 +642,7 @@ func (s *Server) UpdateSpec(ctx context.Context, q *application.ApplicationUpdat if err != nil { return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionUpdate, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionUpdate, apputil.AppRBACName(*a)); err != nil { return nil, err } a.Spec = *q.GetSpec() @@ -666,7 +665,7 @@ func (s *Server) Patch(ctx context.Context, q *application.ApplicationPatchReque return nil, err } - if err = s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionUpdate, appRBACName(*app)); err != nil { + if err = s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionUpdate, apputil.AppRBACName(*app)); err != nil { return nil, err } @@ -714,7 +713,7 @@ func (s *Server) Delete(ctx context.Context, q *application.ApplicationDeleteReq s.projectLock.RLock(a.Spec.Project) defer s.projectLock.RUnlock(a.Spec.Project) - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionDelete, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionDelete, apputil.AppRBACName(*a)); err != nil { return nil, err } @@ -802,7 +801,7 @@ func (s *Server) Watch(q *application.ApplicationQuery, ws application.Applicati return } - if !s.enf.Enforce(claims, rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName(a)) { + if !s.enf.Enforce(claims, rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, apputil.AppRBACName(a)) { // do not emit apps user does not have accessing return } @@ -865,11 +864,11 @@ func (s *Server) validateAndNormalizeApp(ctx context.Context, app *appv1.Applica if currApp != nil && currApp.Spec.GetProject() != app.Spec.GetProject() { // When changing projects, caller must have application create & update privileges in new project // NOTE: the update check was already verified in the caller to this function - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionCreate, appRBACName(*app)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionCreate, apputil.AppRBACName(*app)); err != nil { return err } // They also need 'update' privileges in the old project - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionUpdate, appRBACName(*currApp)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionUpdate, apputil.AppRBACName(*currApp)); err != nil { return err } } @@ -950,7 +949,7 @@ func (s *Server) getCachedAppState(ctx context.Context, a *appv1.Application, ge return err } -func (s *Server) getAppResources(ctx context.Context, a *appv1.Application) (*appv1.ApplicationTree, error) { +func (s *Server) GetAppResources(ctx context.Context, a *appv1.Application) (*appv1.ApplicationTree, error) { var tree appv1.ApplicationTree err := s.getCachedAppState(ctx, a, func() error { return s.cache.GetAppResourcesTree(a.Name, &tree) @@ -963,11 +962,11 @@ func (s *Server) getAppLiveResource(ctx context.Context, action string, q *appli if err != nil { return nil, nil, nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, action, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, action, apputil.AppRBACName(*a)); err != nil { return nil, nil, nil, err } - tree, err := s.getAppResources(ctx, a) + tree, err := s.GetAppResources(ctx, a) if err != nil { return nil, nil, nil, err } @@ -1034,7 +1033,7 @@ func (s *Server) PatchResource(ctx context.Context, q *application.ApplicationRe if err != nil { return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionUpdate, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionUpdate, apputil.AppRBACName(*a)); err != nil { return nil, err } @@ -1075,7 +1074,7 @@ func (s *Server) DeleteResource(ctx context.Context, q *application.ApplicationR if err != nil { return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionDelete, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionDelete, apputil.AppRBACName(*a)); err != nil { return nil, err } var deleteOption metav1.DeleteOptions @@ -1103,10 +1102,10 @@ func (s *Server) ResourceTree(ctx context.Context, q *application.ResourcesQuery if err != nil { return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, apputil.AppRBACName(*a)); err != nil { return nil, err } - return s.getAppResources(ctx, a) + return s.GetAppResources(ctx, a) } func (s *Server) WatchResourceTree(q *application.ResourcesQuery, ws application.ApplicationService_WatchResourceTreeServer) error { @@ -1115,7 +1114,7 @@ func (s *Server) WatchResourceTree(q *application.ResourcesQuery, ws application return err } - if err := s.enf.EnforceErr(ws.Context().Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ws.Context().Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, apputil.AppRBACName(*a)); err != nil { return err } @@ -1134,7 +1133,7 @@ func (s *Server) RevisionMetadata(ctx context.Context, q *application.RevisionMe if err != nil { return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, apputil.AppRBACName(*a)); err != nil { return nil, err } repo, err := s.db.GetRepository(ctx, a.Spec.Source.RepoURL) @@ -1171,7 +1170,7 @@ func (s *Server) ManagedResources(ctx context.Context, q *application.ResourcesQ if err != nil { return nil, fmt.Errorf("error getting application: %s", err) } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, apputil.AppRBACName(*a)); err != nil { return nil, fmt.Errorf("error verifying rbac: %s", err) } items := make([]*appv1.ResourceDiff, 0) @@ -1231,7 +1230,7 @@ func (s *Server) PodLogs(q *application.ApplicationPodLogsQuery, ws application. return err } - if err := s.enf.EnforceErr(ws.Context().Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ws.Context().Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, apputil.AppRBACName(*a)); err != nil { return err } @@ -1246,12 +1245,12 @@ func (s *Server) PodLogs(q *application.ApplicationPodLogsQuery, ws application. } if serverRBACLogEnforceEnable { - if err := s.enf.EnforceErr(ws.Context().Value("claims"), rbacpolicy.ResourceLogs, rbacpolicy.ActionGet, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ws.Context().Value("claims"), rbacpolicy.ResourceLogs, rbacpolicy.ActionGet, apputil.AppRBACName(*a)); err != nil { return err } } - tree, err := s.getAppResources(ws.Context(), a) + tree, err := s.GetAppResources(ws.Context(), a) if err != nil { return err } @@ -1441,11 +1440,11 @@ func (s *Server) Sync(ctx context.Context, syncReq *application.ApplicationSyncR return a, status.Errorf(codes.PermissionDenied, "Cannot sync: Blocked by sync window") } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionSync, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionSync, apputil.AppRBACName(*a)); err != nil { return nil, err } if syncReq.Manifests != nil { - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionOverride, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionOverride, apputil.AppRBACName(*a)); err != nil { return nil, err } if a.Spec.SyncPolicy != nil && a.Spec.SyncPolicy.Automated != nil && !syncReq.GetDryRun() { @@ -1529,7 +1528,7 @@ func (s *Server) Rollback(ctx context.Context, rollbackReq *application.Applicat if err != nil { return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionSync, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionSync, apputil.AppRBACName(*a)); err != nil { return nil, err } if a.DeletionTimestamp != nil { @@ -1621,7 +1620,7 @@ func (s *Server) TerminateOperation(ctx context.Context, termOpReq *application. if err != nil { return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionSync, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionSync, apputil.AppRBACName(*a)); err != nil { return nil, err } @@ -1860,7 +1859,7 @@ func (s *Server) GetApplicationSyncWindows(ctx context.Context, q *application.A return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName(*a)); err != nil { + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, apputil.AppRBACName(*a)); err != nil { return nil, err } diff --git a/server/application/application_test.go b/server/application/application_test.go index 136c59316b934..22f8fbf08be1f 100644 --- a/server/application/application_test.go +++ b/server/application/application_test.go @@ -196,7 +196,7 @@ func newTestAppServerWithEnforcerConfigure(f func(*rbac.Enforcer), objects ...ru panic("Timed out waiting for caches to sync") } - server := NewServer( + server, _ := NewServer( testNamespace, kubeclientset, fakeAppsClientset, diff --git a/server/application/terminal.go b/server/application/terminal.go new file mode 100644 index 0000000000000..8a41a21d85c37 --- /dev/null +++ b/server/application/terminal.go @@ -0,0 +1,232 @@ +package application + +import ( + "context" + "io" + "net/http" + + "github.com/argoproj/gitops-engine/pkg/utils/kube" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" + + appv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + applisters "github.com/argoproj/argo-cd/v2/pkg/client/listers/application/v1alpha1" + servercache "github.com/argoproj/argo-cd/v2/server/cache" + "github.com/argoproj/argo-cd/v2/server/rbacpolicy" + apputil "github.com/argoproj/argo-cd/v2/util/app" + "github.com/argoproj/argo-cd/v2/util/argo" + "github.com/argoproj/argo-cd/v2/util/db" + "github.com/argoproj/argo-cd/v2/util/rbac" +) + +type terminalHandler struct { + appLister applisters.ApplicationNamespaceLister + db db.ArgoDB + enf *rbac.Enforcer + cache *servercache.Cache + appResourceTreeFn func(ctx context.Context, app *appv1.Application) (*appv1.ApplicationTree, error) +} + +// NewHandler returns a new terminal handler. +func NewHandler(appLister applisters.ApplicationNamespaceLister, db db.ArgoDB, enf *rbac.Enforcer, cache *servercache.Cache, + appResourceTree AppResourceTreeFn) *terminalHandler { + return &terminalHandler{ + appLister: appLister, + db: db, + enf: enf, + cache: cache, + appResourceTreeFn: appResourceTree, + } +} + +func (s *terminalHandler) getApplicationClusterRawConfig(ctx context.Context, a *appv1.Application) (*rest.Config, error) { + if err := argo.ValidateDestination(ctx, &a.Spec.Destination, s.db); err != nil { + return nil, err + } + clst, err := s.db.GetCluster(ctx, a.Spec.Destination.Server) + if err != nil { + return nil, err + } + return clst.RawRestConfig(), nil +} + +func (s *terminalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + podName := q.Get("pod") + container := q.Get("container") + app := q.Get("appName") + namespace := q.Get("namespace") + shell := q.Get("shell") + + if podName == "" || container == "" || app == "" || namespace == "" { + http.Error(w, "Missing required parameters", http.StatusBadRequest) + return + } + + ctx := r.Context() + a, err := s.appLister.Get(app) + if err != nil { + http.Error(w, "Cannot get app", http.StatusBadRequest) + return + } + + appRBACName := apputil.AppRBACName(*a) + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceExec, rbacpolicy.ActionGet, appRBACName); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + config, err := s.getApplicationClusterRawConfig(ctx, a) + if err != nil { + http.Error(w, "Cannot get raw cluster config", http.StatusBadRequest) + return + } + + kubeClientset, err := kubernetes.NewForConfig(config) + if err != nil { + http.Error(w, "Cannot initialize kubeclient", http.StatusBadRequest) + return + } + + resourceTree, err := s.appResourceTreeFn(ctx, a) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // From the tree find pods which match the given pod. + if !podExists(resourceTree.Nodes, podName, namespace) { + http.Error(w, "Pod doesn't belong to specified app", http.StatusBadRequest) + return + } + + pod, err := kubeClientset.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{}) + if err != nil { + http.Error(w, "Cannot find pod", http.StatusBadRequest) + return + } + + if pod.Status.Phase != v1.PodRunning { + http.Error(w, "Pod not running", http.StatusBadRequest) + return + } + + var findContainer bool + for _, c := range pod.Spec.Containers { + if container == c.Name { + findContainer = true + break + } + } + if !findContainer { + http.Error(w, "Cannot find container", http.StatusBadRequest) + return + } + + session, err := newTerminalSession(w, r, nil) + if err != nil { + http.Error(w, "Failed to start terminal session", http.StatusBadRequest) + return + } + defer session.Done() + + validShells := []string{"bash", "sh", "powershell", "cmd"} + if isValidShell(validShells, shell) { + cmd := []string{shell} + err = startProcess(kubeClientset, config, namespace, podName, container, cmd, session) + } else { + // No shell given or it was not valid: try some shells until one succeeds or all fail + // FIXME: if the first shell fails then the first keyboard event is lost + for _, testShell := range validShells { + cmd := []string{testShell} + if err = startProcess(kubeClientset, config, namespace, podName, container, cmd, session); err == nil { + break + } + } + } + + if err != nil { + http.Error(w, "Failed to exec container", http.StatusBadRequest) + session.Close() + return + } + + session.Close() +} + +func podExists(treeNodes []appv1.ResourceNode, podName, namespace string) bool { + for _, treeNode := range treeNodes { + if treeNode.Kind == kube.PodKind && treeNode.Group == "" && treeNode.UID != "" && + treeNode.Name == podName && treeNode.Namespace == namespace { + return true + } + } + return false +} + +const EndOfTransmission = "\u0004" + +// PtyHandler is what remotecommand expects from a pty +type PtyHandler interface { + io.Reader + io.Writer + remotecommand.TerminalSizeQueue +} + +// TerminalMessage is the struct for websocket message. +type TerminalMessage struct { + Operation string `json:"operation"` + Data string `json:"data"` + Rows uint16 `json:"rows"` + Cols uint16 `json:"cols"` +} + +// startProcess executes specified commands in the container and connects it up with the ptyHandler (a session) +func startProcess(k8sClient kubernetes.Interface, cfg *rest.Config, namespace, podName, containerName string, cmd []string, ptyHandler PtyHandler) error { + req := k8sClient.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(podName). + Namespace(namespace). + SubResource("exec") + + req.VersionedParams(&v1.PodExecOptions{ + Container: containerName, + Command: cmd, + Stdin: true, + Stdout: true, + Stderr: true, + TTY: true, + }, scheme.ParameterCodec) + + exec, err := remotecommand.NewSPDYExecutor(cfg, "POST", req.URL()) + if err != nil { + return err + } + + return exec.Stream(remotecommand.StreamOptions{ + Stdin: ptyHandler, + Stdout: ptyHandler, + Stderr: ptyHandler, + TerminalSizeQueue: ptyHandler, + Tty: true, + }) +} + +// isValidShell checks if the shell is an allowed one +func isValidShell(validShells []string, shell string) bool { + for _, validShell := range validShells { + if validShell == shell { + return true + } + } + return false +} diff --git a/server/application/terminal_test.go b/server/application/terminal_test.go new file mode 100644 index 0000000000000..3c8f6f9c9f17f --- /dev/null +++ b/server/application/terminal_test.go @@ -0,0 +1,76 @@ +package application + +import ( + "testing" + + "github.com/argoproj/gitops-engine/pkg/utils/kube" + + appv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" +) + +func TestPodExists(t *testing.T) { + for _, tcase := range []struct { + name string + podName string + namespace string + treeNodes []appv1.ResourceNode + expectedResult bool + }{ + { + name: "empty tree nodes", + podName: "test-pod", + namespace: "test", + treeNodes: []appv1.ResourceNode{}, + expectedResult: false, + }, + { + name: "matched Pod but empty UID", + podName: "test-pod", + namespace: "test", + treeNodes: []appv1.ResourceNode{{ResourceRef: appv1.ResourceRef{Name: "test-pod", Namespace: "test", UID: "", Kind: kube.PodKind}}}, + expectedResult: false, + }, + { + name: "matched Pod", + podName: "test-pod", + namespace: "test", + treeNodes: []appv1.ResourceNode{{ResourceRef: appv1.ResourceRef{Name: "test-pod", Namespace: "test", UID: "testUID", Kind: kube.PodKind}}}, + expectedResult: true, + }, + { + name: "unmatched Pod Namespace", + podName: "test-pod", + namespace: "test", + treeNodes: []appv1.ResourceNode{{ResourceRef: appv1.ResourceRef{Name: "test-pod", Namespace: "test-A", UID: "testUID", Kind: kube.PodKind}}}, + expectedResult: false, + }, + { + name: "unmatched Kind", + podName: "test-pod", + namespace: "test", + treeNodes: []appv1.ResourceNode{{ResourceRef: appv1.ResourceRef{Name: "test-pod", Namespace: "test-A", UID: "testUID", Kind: kube.DeploymentKind}}}, + expectedResult: false, + }, + { + name: "unmatched Group", + podName: "test-pod", + namespace: "test", + treeNodes: []appv1.ResourceNode{{ResourceRef: appv1.ResourceRef{Name: "test-pod", Namespace: "test", UID: "testUID", Group: "A", Kind: kube.PodKind}}}, + expectedResult: false, + }, + { + name: "unmatched Pod Name", + podName: "test-pod", + namespace: "test", + treeNodes: []appv1.ResourceNode{{ResourceRef: appv1.ResourceRef{Name: "test", Namespace: "test", UID: "testUID", Kind: kube.PodKind}}}, + expectedResult: false, + }, + } { + t.Run(tcase.name, func(t *testing.T) { + result := podExists(tcase.treeNodes, tcase.podName, tcase.namespace) + if result != tcase.expectedResult { + t.Errorf("Expected result %v, but got %v", tcase.expectedResult, result) + } + }) + } +} diff --git a/server/application/websocket.go b/server/application/websocket.go new file mode 100644 index 0000000000000..cf2c4db288910 --- /dev/null +++ b/server/application/websocket.go @@ -0,0 +1,104 @@ +package application + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/gorilla/websocket" + log "github.com/sirupsen/logrus" + "k8s.io/client-go/tools/remotecommand" +) + +var upgrader = func() websocket.Upgrader { + upgrader := websocket.Upgrader{} + upgrader.HandshakeTimeout = time.Second * 2 + upgrader.CheckOrigin = func(r *http.Request) bool { + return true + } + return upgrader +}() + +// terminalSession implements PtyHandler +type terminalSession struct { + wsConn *websocket.Conn + sizeChan chan remotecommand.TerminalSize + doneChan chan struct{} + tty bool +} + +// newTerminalSession create terminalSession +func newTerminalSession(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*terminalSession, error) { + conn, err := upgrader.Upgrade(w, r, responseHeader) + if err != nil { + return nil, err + } + session := &terminalSession{ + wsConn: conn, + tty: true, + sizeChan: make(chan remotecommand.TerminalSize), + doneChan: make(chan struct{}), + } + return session, nil +} + +// Done close the done channel. +func (t *terminalSession) Done() { + close(t.doneChan) +} + +// Next called in a loop from remotecommand as long as the process is running +func (t *terminalSession) Next() *remotecommand.TerminalSize { + select { + case size := <-t.sizeChan: + return &size + case <-t.doneChan: + return nil + } +} + +// Read called in a loop from remotecommand as long as the process is running +func (t *terminalSession) Read(p []byte) (int, error) { + _, message, err := t.wsConn.ReadMessage() + if err != nil { + log.Errorf("read message err: %v", err) + return copy(p, EndOfTransmission), err + } + var msg TerminalMessage + if err := json.Unmarshal(message, &msg); err != nil { + log.Errorf("read parse message err: %v", err) + return copy(p, EndOfTransmission), err + } + switch msg.Operation { + case "stdin": + return copy(p, msg.Data), nil + case "resize": + t.sizeChan <- remotecommand.TerminalSize{Width: msg.Cols, Height: msg.Rows} + return 0, nil + default: + return copy(p, EndOfTransmission), fmt.Errorf("unknown message type %s", msg.Operation) + } +} + +// Write called from remotecommand whenever there is any output +func (t *terminalSession) Write(p []byte) (int, error) { + msg, err := json.Marshal(TerminalMessage{ + Operation: "stdout", + Data: string(p), + }) + if err != nil { + log.Errorf("write parse message err: %v", err) + return 0, err + } + if err := t.wsConn.WriteMessage(websocket.TextMessage, msg); err != nil { + log.Errorf("write message err: %v", err) + return 0, err + } + return len(p), nil +} + +// Close closes websocket connection +func (t *terminalSession) Close() error { + return t.wsConn.Close() +} diff --git a/server/rbacpolicy/rbacpolicy.go b/server/rbacpolicy/rbacpolicy.go index 0d4270a192887..f0ff9d73ef901 100644 --- a/server/rbacpolicy/rbacpolicy.go +++ b/server/rbacpolicy/rbacpolicy.go @@ -22,6 +22,7 @@ const ( ResourceAccounts = "accounts" ResourceGPGKeys = "gpgkeys" ResourceLogs = "logs" + ResourceExec = "exec" // please add new items to Actions ActionGet = "get" @@ -42,6 +43,7 @@ var ( ResourceRepositories, ResourceCertificates, ResourceLogs, + ResourceExec, } Actions = []string{ ActionGet, @@ -169,7 +171,7 @@ func (p *RBACPolicyEnforcer) getProjectFromRequest(rvals ...interface{}) *v1alph if res, ok := rvals[1].(string); ok { if obj, ok := rvals[3].(string); ok { switch res { - case ResourceApplications, ResourceRepositories, ResourceClusters, ResourceLogs: + case ResourceApplications, ResourceRepositories, ResourceClusters, ResourceLogs, ResourceExec: if objSplit := strings.Split(obj, "/"); len(objSplit) >= 2 { return getProjectByName(objSplit[0]) } diff --git a/server/rbacpolicy/rbacpolicy_test.go b/server/rbacpolicy/rbacpolicy_test.go index c20ac5b2f9073..f14b6b51c71a4 100644 --- a/server/rbacpolicy/rbacpolicy_test.go +++ b/server/rbacpolicy/rbacpolicy_test.go @@ -31,6 +31,7 @@ func newFakeProj() *argoappv1.AppProject { Policies: []string{ "p, proj:my-proj:my-role, applications, create, my-proj/*, allow", "p, proj:my-proj:my-role, logs, get, my-proj/*, allow", + "p, proj:my-proj:my-role, exec, get, my-proj/*, allow", }, Groups: []string{ "my-org:my-team", @@ -52,30 +53,35 @@ func TestEnforceAllPolicies(t *testing.T) { projLister := test.NewFakeProjLister(newFakeProj()) enf := rbac.NewEnforcer(kubeclientset, test.FakeArgoCDNamespace, common.ArgoCDConfigMapName, nil) enf.EnableLog(true) - _ = enf.SetBuiltinPolicy(`p, alice, applications, create, my-proj/*, allow` + "\n" + `p, alice, logs, get, my-proj/*, allow`) - _ = enf.SetUserPolicy(`p, bob, applications, create, my-proj/*, allow` + "\n" + `p, bob, logs, get, my-proj/*, allow`) + _ = enf.SetBuiltinPolicy(`p, alice, applications, create, my-proj/*, allow` + "\n" + `p, alice, logs, get, my-proj/*, allow` + "\n" + `p, alice, exec, get, my-proj/*, allow`) + _ = enf.SetUserPolicy(`p, bob, applications, create, my-proj/*, allow` + "\n" + `p, bob, logs, get, my-proj/*, allow` + "\n" + `p, bob, exec, get, my-proj/*, allow`) rbacEnf := NewRBACPolicyEnforcer(enf, projLister) enf.SetClaimsEnforcerFunc(rbacEnf.EnforceClaims) claims := jwt.MapClaims{"sub": "alice"} assert.True(t, enf.Enforce(claims, "applications", "create", "my-proj/my-app")) assert.True(t, enf.Enforce(claims, "logs", "get", "my-proj/my-app")) + assert.True(t, enf.Enforce(claims, "exec", "get", "my-proj/my-app")) claims = jwt.MapClaims{"sub": "bob"} assert.True(t, enf.Enforce(claims, "applications", "create", "my-proj/my-app")) assert.True(t, enf.Enforce(claims, "logs", "get", "my-proj/my-app")) + assert.True(t, enf.Enforce(claims, "exec", "get", "my-proj/my-app")) claims = jwt.MapClaims{"sub": "proj:my-proj:my-role", "iat": 1234} assert.True(t, enf.Enforce(claims, "applications", "create", "my-proj/my-app")) assert.True(t, enf.Enforce(claims, "logs", "get", "my-proj/my-app")) + assert.True(t, enf.Enforce(claims, "exec", "get", "my-proj/my-app")) claims = jwt.MapClaims{"groups": []string{"my-org:my-team"}} assert.True(t, enf.Enforce(claims, "applications", "create", "my-proj/my-app")) assert.True(t, enf.Enforce(claims, "logs", "get", "my-proj/my-app")) + assert.True(t, enf.Enforce(claims, "exec", "get", "my-proj/my-app")) claims = jwt.MapClaims{"sub": "cathy"} assert.False(t, enf.Enforce(claims, "applications", "create", "my-proj/my-app")) assert.False(t, enf.Enforce(claims, "logs", "get", "my-proj/my-app")) + assert.False(t, enf.Enforce(claims, "exec", "get", "my-proj/my-app")) // AWS cognito returns its groups in cognito:groups rbacEnf.SetScopes([]string{"cognito:groups"}) @@ -121,36 +127,42 @@ func TestInvalidatedCache(t *testing.T) { projLister := test.NewFakeProjLister(newFakeProj()) enf := rbac.NewEnforcer(kubeclientset, test.FakeArgoCDNamespace, common.ArgoCDConfigMapName, nil) enf.EnableLog(true) - _ = enf.SetBuiltinPolicy(`p, alice, applications, create, my-proj/*, allow` + "\n" + `p, alice, logs, get, my-proj/*, allow`) - _ = enf.SetUserPolicy(`p, bob, applications, create, my-proj/*, allow` + "\n" + `p, bob, logs, get, my-proj/*, allow`) + _ = enf.SetBuiltinPolicy(`p, alice, applications, create, my-proj/*, allow` + "\n" + `p, alice, logs, get, my-proj/*, allow` + "\n" + `p, alice, exec, get, my-proj/*, allow`) + _ = enf.SetUserPolicy(`p, bob, applications, create, my-proj/*, allow` + "\n" + `p, bob, logs, get, my-proj/*, allow` + "\n" + `p, bob, exec, get, my-proj/*, allow`) rbacEnf := NewRBACPolicyEnforcer(enf, projLister) enf.SetClaimsEnforcerFunc(rbacEnf.EnforceClaims) claims := jwt.MapClaims{"sub": "alice"} assert.True(t, enf.Enforce(claims, "applications", "create", "my-proj/my-app")) assert.True(t, enf.Enforce(claims, "logs", "get", "my-proj/my-app")) + assert.True(t, enf.Enforce(claims, "exec", "get", "my-proj/my-app")) claims = jwt.MapClaims{"sub": "bob"} assert.True(t, enf.Enforce(claims, "applications", "create", "my-proj/my-app")) assert.True(t, enf.Enforce(claims, "logs", "get", "my-proj/my-app")) + assert.True(t, enf.Enforce(claims, "exec", "get", "my-proj/my-app")) - _ = enf.SetBuiltinPolicy(`p, alice, applications, create, my-proj2/*, allow` + "\n" + `p, alice, logs, get, my-proj2/*, allow`) - _ = enf.SetUserPolicy(`p, bob, applications, create, my-proj2/*, allow` + "\n" + `p, bob, logs, get, my-proj2/*, allow`) + _ = enf.SetBuiltinPolicy(`p, alice, applications, create, my-proj2/*, allow` + "\n" + `p, alice, logs, get, my-proj2/*, allow` + "\n" + `p, alice, exec, get, my-proj2/*, allow`) + _ = enf.SetUserPolicy(`p, bob, applications, create, my-proj2/*, allow` + "\n" + `p, bob, logs, get, my-proj2/*, allow` + "\n" + `p, bob, exec, get, my-proj2/*, allow`) claims = jwt.MapClaims{"sub": "alice"} assert.True(t, enf.Enforce(claims, "applications", "create", "my-proj2/my-app")) assert.True(t, enf.Enforce(claims, "logs", "get", "my-proj2/my-app")) + assert.True(t, enf.Enforce(claims, "exec", "get", "my-proj2/my-app")) claims = jwt.MapClaims{"sub": "bob"} assert.True(t, enf.Enforce(claims, "applications", "create", "my-proj2/my-app")) assert.True(t, enf.Enforce(claims, "logs", "get", "my-proj2/my-app")) + assert.True(t, enf.Enforce(claims, "exec", "get", "my-proj2/my-app")) claims = jwt.MapClaims{"sub": "alice"} assert.False(t, enf.Enforce(claims, "applications", "create", "my-proj/my-app")) assert.False(t, enf.Enforce(claims, "logs", "get", "my-proj/my-app")) + assert.False(t, enf.Enforce(claims, "exec", "get", "my-proj/my-app")) claims = jwt.MapClaims{"sub": "bob"} assert.False(t, enf.Enforce(claims, "applications", "create", "my-proj/my-app")) assert.False(t, enf.Enforce(claims, "logs", "get", "my-proj/my-app")) + assert.False(t, enf.Enforce(claims, "exec", "get", "my-proj/my-app")) } func TestGetScopes_DefaultScopes(t *testing.T) { diff --git a/server/server.go b/server/server.go index a9375bf39907d..d1160ae3a1891 100644 --- a/server/server.go +++ b/server/server.go @@ -162,6 +162,7 @@ type ArgoCDServer struct { policyEnforcer *rbacpolicy.RBACPolicyEnforcer appInformer cache.SharedIndexInformer appLister applisters.ApplicationNamespaceLister + db db.ArgoDB // stopCh is the channel which when closed, will shutdown the Argo CD server stopCh chan struct{} @@ -260,6 +261,7 @@ func NewServer(ctx context.Context, opts ArgoCDServerOpts) *ArgoCDServer { policyEnforcer: policyEnf, userStateStorage: userStateStorage, staticAssets: http.FS(staticFS), + db: db.NewDB(opts.Namespace, settingsMgr, opts.KubeClientset), } } @@ -286,15 +288,15 @@ func (a *ArgoCDServer) healthCheck(r *http.Request) error { func (a *ArgoCDServer) Run(ctx context.Context, port int, metricsPort int) { a.userStateStorage.Init(ctx) - grpcS := a.newGRPCServer() + grpcS, appResourceTreeFn := a.newGRPCServer() grpcWebS := grpcweb.WrapServer(grpcS) var httpS *http.Server var httpsS *http.Server if a.useTLS() { httpS = newRedirectServer(port, a.RootPath) - httpsS = a.newHTTPServer(ctx, port, grpcWebS) + httpsS = a.newHTTPServer(ctx, port, grpcWebS, appResourceTreeFn) } else { - httpS = a.newHTTPServer(ctx, port, grpcWebS) + httpS = a.newHTTPServer(ctx, port, grpcWebS, appResourceTreeFn) } if a.RootPath != "" { httpS.Handler = withRootPath(httpS.Handler, a) @@ -526,7 +528,7 @@ func (a *ArgoCDServer) useTLS() bool { return true } -func (a *ArgoCDServer) newGRPCServer() *grpc.Server { +func (a *ArgoCDServer) newGRPCServer() (*grpc.Server, application.AppResourceTreeFn) { if enableGRPCTimeHistogram { grpc_prometheus.EnableHandlingTimeHistogram() } @@ -582,18 +584,17 @@ func (a *ArgoCDServer) newGRPCServer() *grpc.Server { grpc_util.PanicLoggerUnaryServerInterceptor(a.log), ))) grpcS := grpc.NewServer(sOpts...) - db := db.NewDB(a.Namespace, a.settingsMgr, a.KubeClientset) kubectl := kubeutil.NewKubectl() - clusterService := cluster.NewServer(db, a.enf, a.Cache, kubectl) - repoService := repository.NewServer(a.RepoClientset, db, a.enf, a.Cache, a.appLister, a.projLister, a.settingsMgr) - repoCredsService := repocreds.NewServer(a.RepoClientset, db, a.enf, a.settingsMgr) + clusterService := cluster.NewServer(a.db, a.enf, a.Cache, kubectl) + repoService := repository.NewServer(a.RepoClientset, a.db, a.enf, a.Cache, a.appLister, a.projLister, a.settingsMgr) + repoCredsService := repocreds.NewServer(a.RepoClientset, a.db, a.enf, a.settingsMgr) var loginRateLimiter func() (io.Closer, error) if maxConcurrentLoginRequestsCount > 0 { loginRateLimiter = session.NewLoginRateLimiter(maxConcurrentLoginRequestsCount) } sessionService := session.NewServer(a.sessionMgr, a.settingsMgr, a, a.policyEnforcer, loginRateLimiter) projectLock := sync.NewKeyLock() - applicationService := application.NewServer( + applicationService, appResourceTreeFn := application.NewServer( a.Namespace, a.KubeClientset, a.AppClientset, @@ -602,16 +603,16 @@ func (a *ArgoCDServer) newGRPCServer() *grpc.Server { a.RepoClientset, a.Cache, kubectl, - db, + a.db, a.enf, projectLock, a.settingsMgr, a.projInformer) - projectService := project.NewServer(a.Namespace, a.KubeClientset, a.AppClientset, a.enf, projectLock, a.sessionMgr, a.policyEnforcer, a.projInformer, a.settingsMgr, db) + projectService := project.NewServer(a.Namespace, a.KubeClientset, a.AppClientset, a.enf, projectLock, a.sessionMgr, a.policyEnforcer, a.projInformer, a.settingsMgr, a.db) settingsService := settings.NewServer(a.settingsMgr, a, a.DisableAuth) accountService := account.NewServer(a.sessionMgr, a.settingsMgr, a.enf) - certificateService := certificate.NewServer(a.RepoClientset, db, a.enf) - gpgkeyService := gpgkey.NewServer(a.RepoClientset, db, a.enf) + certificateService := certificate.NewServer(a.RepoClientset, a.db, a.enf) + gpgkeyService := gpgkey.NewServer(a.RepoClientset, a.db, a.enf) versionpkg.RegisterVersionServiceServer(grpcS, version.NewServer(a, func() (bool, error) { if a.DisableAuth { return true, nil @@ -636,7 +637,7 @@ func (a *ArgoCDServer) newGRPCServer() *grpc.Server { reflection.Register(grpcS) grpc_prometheus.Register(grpcS) errors.CheckError(projectService.NormalizeProjs()) - return grpcS + return grpcS, appResourceTreeFn } // translateGrpcCookieHeader conditionally sets a cookie on the response. @@ -698,7 +699,7 @@ func compressHandler(handler http.Handler) http.Handler { // newHTTPServer returns the HTTP server to serve HTTP/HTTPS requests. This is implemented // using grpc-gateway as a proxy to the gRPC server. -func (a *ArgoCDServer) newHTTPServer(ctx context.Context, port int, grpcWebHandler http.Handler) *http.Server { +func (a *ArgoCDServer) newHTTPServer(ctx context.Context, port int, grpcWebHandler http.Handler, appResourceTreeFn application.AppResourceTreeFn) *http.Server { endpoint := fmt.Sprintf("localhost:%d", port) mux := http.NewServeMux() httpS := http.Server{ @@ -748,6 +749,30 @@ func (a *ArgoCDServer) newHTTPServer(ctx context.Context, port int, grpcWebHandl handler = compressHandler(handler) } mux.Handle("/api/", handler) + terminalHandler := application.NewHandler(a.appLister, a.db, a.enf, a.Cache, appResourceTreeFn) + mux.HandleFunc("/terminal", func(writer http.ResponseWriter, request *http.Request) { + if !a.DisableAuth { + ctx := request.Context() + cookies := request.Cookies() + tokenString, err := httputil.JoinCookies(common.AuthCookieName, cookies) + if err == nil && jwtutil.IsValid(tokenString) { + claims, _, err := a.sessionMgr.VerifyToken(tokenString) + if err != nil { + // nolint:staticcheck + ctx = context.WithValue(ctx, util_session.AuthErrorCtxKey, err) + } else if claims != nil { + // Add claims to the context to inspect for RBAC + // nolint:staticcheck + ctx = context.WithValue(ctx, "claims", claims) + } + request = request.WithContext(ctx) + } else { + writer.WriteHeader(http.StatusUnauthorized) + return + } + } + terminalHandler.ServeHTTP(writer, request) + }) mustRegisterGWHandler(versionpkg.RegisterVersionServiceHandlerFromEndpoint, ctx, gwmux, endpoint, dOpts) mustRegisterGWHandler(clusterpkg.RegisterClusterServiceHandlerFromEndpoint, ctx, gwmux, endpoint, dOpts) diff --git a/ui/jest.config.js b/ui/jest.config.js index 122b76525080e..abd8a45bcecd6 100644 --- a/ui/jest.config.js +++ b/ui/jest.config.js @@ -5,6 +5,7 @@ module.exports = { collectCoverage: true, transformIgnorePatterns: ['node_modules/(?!(argo-ui)/)'], globals: { + 'self': {}, 'window': {localStorage: { getItem: () => '{}', setItem: () => null }}, 'ts-jest': { isolatedModules: true, diff --git a/ui/package.json b/ui/package.json index 9c42a85ee6c9a..7f9b41922c405 100644 --- a/ui/package.json +++ b/ui/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "ansi-to-react": "^6.1.6", - "argo-ui": "git+https://github.com/argoproj/argo-ui.git", + "argo-ui": "git+https://github.com/argoproj/argo-ui.git#97d60381c71d2c515746837e6c9e4876c21ddb6e", "buffer": "^6.0.3", "classnames": "^2.2.5", "color": "^3.2.1", @@ -46,7 +46,9 @@ "superagent-promise": "^1.1.0", "timezones-list": "3.0.1", "unidiff": "^1.0.2", - "url": "^0.11.0" + "url": "^0.11.0", + "xterm": "^4.18.0", + "xterm-addon-fit": "^0.5.0" }, "resolutions": { "@types/react": "^16.9.3", diff --git a/ui/src/app/applications/components/application-pod-view/pod-view.tsx b/ui/src/app/applications/components/application-pod-view/pod-view.tsx index 19c78288dec06..d913aabceb27a 100644 --- a/ui/src/app/applications/components/application-pod-view/pod-view.tsx +++ b/ui/src/app/applications/components/application-pod-view/pod-view.tsx @@ -246,6 +246,16 @@ export class PodView extends React.Component { this.appContext.apis.navigation.goto('.', {node: pod.fullName, tab: 'logs'}, {replace: true}); } }, + { + title: ( + + Exec + + ), + action: () => { + this.appContext.apis.navigation.goto('.', {node: pod.fullName, tab: 'exec'}, {replace: true}); + } + }, { title: ( diff --git a/ui/src/app/applications/components/pod-terminal-viewer/pod-terminal-viewer.scss b/ui/src/app/applications/components/pod-terminal-viewer/pod-terminal-viewer.scss new file mode 100644 index 0000000000000..6190cd23c02e0 --- /dev/null +++ b/ui/src/app/applications/components/pod-terminal-viewer/pod-terminal-viewer.scss @@ -0,0 +1,7 @@ +.pod-terminal-viewer { + height: 780px; + + .xterm { + padding: 10px 20px; + } +} diff --git a/ui/src/app/applications/components/pod-terminal-viewer/pod-terminal-viewer.tsx b/ui/src/app/applications/components/pod-terminal-viewer/pod-terminal-viewer.tsx new file mode 100644 index 0000000000000..4966a61df30d6 --- /dev/null +++ b/ui/src/app/applications/components/pod-terminal-viewer/pod-terminal-viewer.tsx @@ -0,0 +1,246 @@ +import {Terminal} from 'xterm'; +import {FitAddon} from 'xterm-addon-fit'; +import * as models from '../../../shared/models'; +import * as React from 'react'; +import './pod-terminal-viewer.scss'; +import 'xterm/css/xterm.css'; +import * as AppUtils from '../utils'; +import {useCallback, useEffect, useState} from 'react'; +import {debounceTime, takeUntil} from 'rxjs/operators'; +import {fromEvent, ReplaySubject, Subject} from 'rxjs'; +import {Context} from '../../../shared/context'; +import {ErrorNotification, NotificationType} from 'argo-ui'; +export interface PodTerminalViewerProps { + applicationName: string; + selectedNode: models.ResourceNode; + podState: models.State; +} +export interface ShellFrame { + operation: string; + data?: string; + rows?: number; + cols?: number; +} + +export const PodTerminalViewer: React.FC = ({selectedNode, applicationName, podState}) => { + const terminalRef = React.useRef(null); + const appContext = React.useContext(Context); // used to show toast + const fitAddon = new FitAddon(); + let terminal: Terminal; + let webSocket: WebSocket; + const keyEvent = new ReplaySubject(2); + const [activeContainer, setActiveContainer] = useState(0); + let connSubject = new ReplaySubject(100); + let incommingMessage = new Subject(); + const unsubscribe = new Subject(); + let connected = false; + + function showErrorMsg(msg: string, err: any) { + appContext.notifications.show({ + content: , + type: NotificationType.Error + }); + } + + const onTerminalSendString = (str: string) => { + if (connected) { + webSocket.send(JSON.stringify({operation: 'stdin', data: str, rows: terminal.rows, cols: terminal.cols})); + } + }; + + const onTerminalResize = () => { + if (connected) { + webSocket.send( + JSON.stringify({ + operation: 'resize', + cols: terminal.cols, + rows: terminal.rows + }) + ); + } + }; + + const onConnectionMessage = (e: MessageEvent) => { + const msg = JSON.parse(e.data); + connSubject.next(msg); + }; + + const onConnectionOpen = () => { + connected = true; + onTerminalResize(); // fit the screen first time + terminal.focus(); + }; + + const onConnectionClose = () => { + if (!connected) return; + if (webSocket) webSocket.close(); + connected = false; + }; + + const handleConnectionMessage = (frame: ShellFrame) => { + terminal.write(frame.data); + incommingMessage.next(frame); + }; + + const disconnect = () => { + if (webSocket) { + webSocket.close(); + } + + if (connSubject) { + connSubject.complete(); + connSubject = new ReplaySubject(100); + } + + if (terminal) { + terminal.dispose(); + } + + incommingMessage.complete(); + incommingMessage = new Subject(); + }; + + function initTerminal(node: HTMLElement) { + if (connSubject) { + connSubject.complete(); + connSubject = new ReplaySubject(100); + } + + if (terminal) { + terminal.dispose(); + } + + terminal = new Terminal({ + convertEol: true, + fontFamily: 'Menlo, Monaco, Courier New, monospace', + bellStyle: 'sound', + fontSize: 14, + fontWeight: 400, + cursorBlink: true + }); + terminal.options = { + theme: { + background: '#333' + } + }; + terminal.loadAddon(fitAddon); + terminal.open(node); + fitAddon.fit(); + + connSubject.pipe(takeUntil(unsubscribe)).subscribe(frame => { + handleConnectionMessage(frame); + }); + + terminal.onResize(onTerminalResize); + terminal.onKey(key => { + keyEvent.next(key.domEvent); + }); + terminal.onData(onTerminalSendString); + } + + function setupConnection() { + const {name = '', namespace = ''} = selectedNode || {}; + webSocket = new WebSocket( + `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/terminal?pod=${name}&container=${AppUtils.getContainerName( + podState, + activeContainer + )}&appName=${applicationName}&namespace=${namespace}` + ); + webSocket.onopen = onConnectionOpen; + webSocket.onclose = onConnectionClose; + webSocket.onerror = e => { + showErrorMsg('Terminal Connection Error', e); + onConnectionClose(); + }; + webSocket.onmessage = onConnectionMessage; + } + + const setTerminalRef = useCallback( + node => { + if (terminal && connected) { + disconnect(); + } + + if (node) { + initTerminal(node); + setupConnection(); + } + + // Save a reference to the node + terminalRef.current = node; + }, + [activeContainer] + ); + + useEffect(() => { + const resizeHandler = fromEvent(window, 'resize') + .pipe(debounceTime(1000)) + .subscribe(() => { + if (fitAddon) { + fitAddon.fit(); + } + }); + return () => { + resizeHandler.unsubscribe(); // unsubscribe resize callback + unsubscribe.next(); + unsubscribe.complete(); + + // clear connection and close terminal + if (webSocket) { + webSocket.close(); + } + + if (connSubject) { + connSubject.complete(); + } + + if (terminal) { + terminal.dispose(); + } + + incommingMessage.complete(); + }; + }, [activeContainer]); + + const containerGroups = [ + { + offset: 0, + title: 'CONTAINERS', + containers: podState.spec.containers || [] + }, + { + offset: (podState.spec.containers || []).length, + title: 'INIT CONTAINERS', + containers: podState.spec.initContainers || [] + } + ]; + + return ( +
+
+ {containerGroups.map(group => ( +
+ {group.containers.length > 0 &&

{group.title}

} + {group.containers.map((container: any, i: number) => ( +
{ + if (group.offset + i !== activeContainer) { + disconnect(); + setActiveContainer(group.offset + i); + } + }}> + {group.offset + i === activeContainer && } + {container.name} +
+ ))} +
+ ))} +
+
+
+
+
+ ); +}; diff --git a/ui/src/app/applications/components/resource-details/resource-details.tsx b/ui/src/app/applications/components/resource-details/resource-details.tsx index 838523d420b34..03018b8addd2f 100644 --- a/ui/src/app/applications/components/resource-details/resource-details.tsx +++ b/ui/src/app/applications/components/resource-details/resource-details.tsx @@ -16,6 +16,7 @@ import {ResourceTreeNode} from '../application-resource-tree/application-resourc import {ApplicationResourcesDiff} from '../application-resources-diff/application-resources-diff'; import {ApplicationSummary} from '../application-summary/application-summary'; import {PodsLogsViewer} from '../pod-logs-viewer/pod-logs-viewer'; +import {PodTerminalViewer} from '../pod-terminal-viewer/pod-terminal-viewer'; import {ResourceIcon} from '../resource-icon'; import {ResourceLabel} from '../resource-label'; import * as AppUtils from '../utils'; @@ -100,6 +101,12 @@ export const ResourceDetails = (props: ResourceDetailsProps) => { />
) + }, + { + key: 'exec', + icon: 'fa fa-terminal', + title: 'Terminal', + content: } ]); } diff --git a/ui/src/app/applications/components/utils.tsx b/ui/src/app/applications/components/utils.tsx index 0532877e1f98e..c8a78dd832b6d 100644 --- a/ui/src/app/applications/components/utils.tsx +++ b/ui/src/app/applications/components/utils.tsx @@ -354,6 +354,13 @@ function getActionItems( action: () => appContext.apis.navigation.goto('.', {node: nodeKey(resource), tab: 'logs'}, {replace: true}) }); } + if (resource.kind === 'Pod') { + items.push({ + title: 'Exec', + iconClassName: 'fa fa-terminal', + action: () => appContext.apis.navigation.goto('.', {node: nodeKey(resource), tab: 'exec'}, {replace: true}) + }); + } if (isQuickStart) { return from([items]); } diff --git a/ui/src/app/webpack.config.js b/ui/src/app/webpack.config.js index 6338fa8c6b25d..a6f58e73874dd 100644 --- a/ui/src/app/webpack.config.js +++ b/ui/src/app/webpack.config.js @@ -105,6 +105,10 @@ const config = { '/extensions': proxyConf, '/api': proxyConf, '/auth': proxyConf, + '/terminal': { + target: process.env.ARGOCD_API_URL || 'ws://localhost:8080', + ws: true, + }, '/swagger-ui': proxyConf, '/swagger.json': proxyConf } diff --git a/ui/yarn.lock b/ui/yarn.lock index 7efa446b5c581..71555564588c7 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -1707,9 +1707,9 @@ integrity sha512-SGGAhXLHDx+PK4YLNcNGa6goPf9XRWQNAUUbffkwVGGXIxmDKWyGGL4inzq2sPmExu431Ekb9aEMn9BkPqEYFA== "@types/json-schema@*", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": - version "7.0.10" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.10.tgz#9b05b7896166cd00e9cbd59864853abf65d9ac23" - integrity sha512-BLO9bBq59vW3fxCpD4o0N4U+DXsvwvIcl+jofw0frQo/GrBFC+/jRZj1E7kgp6dvTyNmA4y6JCV5Id/r3mNP5A== + version "7.0.11" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" + integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.7": version "7.0.8" @@ -2326,9 +2326,9 @@ arg@^4.1.0: resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== -"argo-ui@git+https://github.com/argoproj/argo-ui.git": +"argo-ui@git+https://github.com/argoproj/argo-ui.git#97d60381c71d2c515746837e6c9e4876c21ddb6e": version "1.0.0" - resolved "git+https://github.com/argoproj/argo-ui.git#4336822c14164f36eb9d0a6b6c3c40560df4c4d7" + resolved "git+https://github.com/argoproj/argo-ui.git#97d60381c71d2c515746837e6c9e4876c21ddb6e" dependencies: "@fortawesome/fontawesome-free" "^5.15.2" "@tippy.js/react" "^2.1.2" @@ -2348,7 +2348,8 @@ arg@^4.1.0: react-toastify "^5.0.1" rxjs "^6.6.6" typescript "^4.1.2" - xterm "2.4.0" + xterm "^4.18.0" + xterm-addon-fit "^0.5.0" argparse@^1.0.7: version "1.0.10" @@ -2665,7 +2666,7 @@ braces@^2.3.1: split-string "^3.0.2" to-regex "^3.0.1" -braces@^3.0.1, braces@~3.0.2: +braces@^3.0.1, braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -4636,9 +4637,9 @@ html-encoding-sniffer@^2.0.1: whatwg-encoding "^1.0.5" html-entities@^2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.2.tgz#760b404685cb1d794e4f4b744332e3b00dcfe488" - integrity sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ== + version "2.3.3" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46" + integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA== html-escaper@^2.0.0: version "2.0.2" @@ -6188,7 +6189,7 @@ micromatch@^3.1.10, micromatch@^3.1.4: snapdragon "^0.8.1" to-regex "^3.0.2" -micromatch@^4.0.0, micromatch@^4.0.2, micromatch@^4.0.4: +micromatch@^4.0.0, micromatch@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9" integrity sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg== @@ -6196,6 +6197,14 @@ micromatch@^4.0.0, micromatch@^4.0.2, micromatch@^4.0.4: braces "^3.0.1" picomatch "^2.2.3" +micromatch@^4.0.2: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + mime-db@1.48.0, "mime-db@>= 1.43.0 < 2": version "1.48.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d" @@ -6468,7 +6477,7 @@ node-fetch@^2.6.1: dependencies: whatwg-url "^5.0.0" -node-forge@^1.2.0: +node-forge@^1: version "1.3.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.0.tgz#37a874ea723855f37db091e6c186e5b67a01d4b2" integrity sha512-08ARB91bUi6zNKzVmaj3QO7cr397uiDT2nJ63cHjyNtCTWIgvS47j3eT0WfzUwS9+6Z5YshRaoasFkXCKrIYbA== @@ -6928,6 +6937,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + pify@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" @@ -8329,11 +8343,11 @@ select-hose@^2.0.0: integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= selfsigned@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.0.0.tgz#e927cd5377cbb0a1075302cff8df1042cc2bce5b" - integrity sha512-cUdFiCbKoa1mZ6osuJs2uDHrs0k0oprsKveFiiaBKCNq3SYyb5gs2HxhQyDNLCmL51ZZThqi4YNDpCK6GOP1iQ== + version "2.0.1" + resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.0.1.tgz#8b2df7fa56bf014d19b6007655fff209c0ef0a56" + integrity sha512-LmME957M1zOsUhG+67rAjKfiWFox3SBxE/yymatMZsAx+oMrJ0YQ8AToOnyCm7xbeg2ep37IHLxdu0o2MavQOQ== dependencies: - node-forge "^1.2.0" + node-forge "^1" "semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.6.0: version "5.7.0" @@ -9878,10 +9892,15 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xterm@2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/xterm/-/xterm-2.4.0.tgz#d70227993b74323e36495ab9c7bdee0bc8d0dbba" - integrity sha1-1wInmTt0Mj42SVq5x73uC8jQ27o= +xterm-addon-fit@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz#2d51b983b786a97dcd6cde805e700c7f913bc596" + integrity sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ== + +xterm@^4.18.0: + version "4.18.0" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1" + integrity sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ== y18n@^5.0.5: version "5.0.8" diff --git a/util/app/app.go b/util/app/app.go new file mode 100644 index 0000000000000..14a4298eec88f --- /dev/null +++ b/util/app/app.go @@ -0,0 +1,11 @@ +package app + +import ( + "fmt" + appv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" +) + +// AppRBACName formats fully qualified application name for RBAC check +func AppRBACName(app appv1.Application) string { + return fmt.Sprintf("%s/%s", app.Spec.GetProject(), app.Name) +}