diff --git a/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/clusterroles.yaml b/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/clusterroles.yaml index 1c54adde..861315fb 100644 --- a/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/clusterroles.yaml +++ b/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/clusterroles.yaml @@ -7,10 +7,23 @@ metadata: rules: #! We need ability to watch for changes to CRDs so kopf can tell if its own #! custom resources have changed. + #! NOTE: Disabled as this results in Educates not being able to be uninstalled + #! when any of the lookup service configuration exists. + #! - apiGroups: + #! - apiextensions.k8s.io + #! resources: + #! - customresourcedefinitions + #! verbs: + #! - get + #! - list + #! - watch + #! We need the ability to watch for namespace changes. This is required by + #! kopf to know when to start and stop watching for changes to the specific + #! namespace is has been told to monitor. - apiGroups: - - apiextensions.k8s.io + - "" resources: - - customresourcedefinitions + - namespaces verbs: - get - list @@ -23,17 +36,6 @@ rules: - events verbs: - create - - #! We need the ability to watch for namespace changes. Also believe this is - #! required by kopf. - - apiGroups: - - "" - resources: - - namespaces - verbs: - - get - - list - - watch #! We need read/write access to the ClusterConfig, ClientConfig and #! TenantConfig custom resources from the lookup.educates.dev API group. - apiGroups: @@ -78,22 +80,23 @@ kind: ClusterRole metadata: name: educates-remote-access rules: -- apiGroups: - - training.educates.dev - resources: - - trainingportals - - workshopenvironments - - workshopsessions - - workshopallocations - verbs: - - get - - list - - watch -- apiGroups: - - "" - resources: - - customresourcedefinitions - verbs: - - get - - list - - watch + - apiGroups: + - training.educates.dev + resources: + - trainingportals + - workshopenvironments + - workshopsessions + - workshopallocations + - workshops + verbs: + - get + - list + - watch + - apiGroups: + - "" + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch diff --git a/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/crd-clientconfig.yaml b/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/crd-clientconfig.yaml index b0e6cfb9..6380ee41 100644 --- a/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/crd-clientconfig.yaml +++ b/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/crd-clientconfig.yaml @@ -23,7 +23,6 @@ spec: type: object required: - client - - tenants - roles properties: client: diff --git a/client-programs/pkg/cmd/admin_cmd_group.go b/client-programs/pkg/cmd/admin_cmd_group.go index 0190b380..2fe94890 100644 --- a/client-programs/pkg/cmd/admin_cmd_group.go +++ b/client-programs/pkg/cmd/admin_cmd_group.go @@ -20,6 +20,7 @@ func (p *ProjectInfo) NewAdminCmdGroup() *cobra.Command { Message: "Available Commands:", Commands: []*cobra.Command{ p.NewAdminPlatformCmdGroup(), + p.NewAdminLookupCmdGroup(), p.NewAdminDiagnosticsCmdGroup(), }, }, diff --git a/client-programs/pkg/cmd/admin_lookup_cmd_group.go b/client-programs/pkg/cmd/admin_lookup_cmd_group.go new file mode 100644 index 00000000..d135f43c --- /dev/null +++ b/client-programs/pkg/cmd/admin_lookup_cmd_group.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" +) + +func (p *ProjectInfo) NewAdminLookupCmdGroup() *cobra.Command { + var c = &cobra.Command{ + Use: "lookup", + Short: "Manage Educates lookup service", + } + + // Use a command group as it allows us to dictate the order in which they + // are displayed in the help message, as otherwise they are displayed in + // sort order. + + commandGroups := templates.CommandGroups{ + { + Message: "Available Commands:", + Commands: []*cobra.Command{ + p.NewAdminLookupKubeconfigCmd(), + }, + }, + } + + commandGroups.Add(c) + + templates.ActsAsRootCommand(c, []string{"--help"}, commandGroups...) + + return c +} diff --git a/client-programs/pkg/cmd/admin_lookup_kubeconfig_cmd.go b/client-programs/pkg/cmd/admin_lookup_kubeconfig_cmd.go new file mode 100644 index 00000000..29a4dc69 --- /dev/null +++ b/client-programs/pkg/cmd/admin_lookup_kubeconfig_cmd.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "context" + "encoding/base64" + "fmt" + "io/ioutil" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/vmware-tanzu-labs/educates-training-platform/client-programs/pkg/cluster" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type LookupConfigOptions struct { + KubeconfigOptions + OutputPath string +} + +func (o *LookupConfigOptions) Run() error { + var err error + + clusterConfig, err := cluster.NewClusterConfigIfAvailable(o.Kubeconfig, o.Context) + if err != nil { + return err + } + + client, err := clusterConfig.GetClient() + + if err != nil { + return err + } + + // We need to fetch the secret called "remote-access-token" from the + // "educates" namespace. This contains a Kubernetes access token secret + // giving access to just the Educates custom resources. + + secretsClient := client.CoreV1().Secrets("educates") + + secret, err := secretsClient.Get(context.TODO(), "remote-access-token", metav1.GetOptions{}) + + if err != nil { + return errors.Wrapf(err, "unable to fetch remote-access secret") + } + + // Within the secret are data fields for "ca.crt" and "token". We need to + // extract these and use them to create a kubeconfig file. Note that there + // is no "server" property in the secret, so when constructing the kubeconfig + // we need to use the server from the same cluster as we are requesting the + // secret from. + + caCrt := secret.Data["ca.crt"] + token := secret.Data["token"] + + // Get the server from the client for Kubernetes cluster access. + + serverScheme := client.CoreV1().RESTClient().Get().URL().Scheme + serverHost := client.CoreV1().RESTClient().Get().URL().Host + + serverUrl := fmt.Sprintf("%s://%s", serverScheme, serverHost) + + // Construct the kubeconfig file. We need to base64 encode the ca.crt file + // as it is a binary file. + + kubeconfig := fmt.Sprintf(`apiVersion: v1 +kind: Config +clusters: +- name: training-platform + cluster: + server: %s + certificate-authority-data: %s +contexts: +- name: training-platform + context: + cluster: training-platform + user: remote-access +current-context: training-platform +users: +- name: remote-access + user: + token: %s +`, serverUrl, base64.StdEncoding.EncodeToString(caCrt), token) + + // Write out the kubeconfig to the output path if provided, otherwise + // print it to stdout. + + if o.OutputPath != "" { + err = ioutil.WriteFile(o.OutputPath, []byte(kubeconfig), 0644) + + if err != nil { + return errors.Wrapf(err, "unable to write kubeconfig to %s", o.OutputPath) + } + } else { + fmt.Print(kubeconfig) + } + + return nil +} + +func (p *ProjectInfo) NewAdminLookupKubeconfigCmd() *cobra.Command { + var o LookupConfigOptions + + var c = &cobra.Command{ + Args: cobra.NoArgs, + Use: "kubeconfig", + Short: "Fetch kubeconfig for lookup service remote access", + RunE: func(cmd *cobra.Command, _ []string) error { + return o.Run() + }, + } + + c.Flags().StringVar( + &o.Kubeconfig, + "kubeconfig", + "", + "kubeconfig file to use instead of $KUBECONFIG or $HOME/.kube/config", + ) + c.Flags().StringVar( + &o.Context, + "context", + "", + "Context to use from Kubeconfig", + ) + c.Flags().StringVarP( + &o.OutputPath, + "output", + "o", + "", + "Path to write Kubeconfig file to", + ) + + return c +} diff --git a/client-programs/pkg/cmd/admin_platform_config_cmd.go b/client-programs/pkg/cmd/admin_platform_config_cmd.go index ae32d530..44839281 100644 --- a/client-programs/pkg/cmd/admin_platform_config_cmd.go +++ b/client-programs/pkg/cmd/admin_platform_config_cmd.go @@ -31,7 +31,7 @@ var ( ` ) -type PkatformConfigOptions struct { +type PlatformConfigOptions struct { KubeconfigOptions Domain string Version string @@ -41,7 +41,7 @@ type PkatformConfigOptions struct { Verbose bool } -func (o *PkatformConfigOptions) Run() error { +func (o *PlatformConfigOptions) Run() error { installer := installer.NewInstaller() if o.FromCluster { @@ -64,7 +64,7 @@ func (o *PkatformConfigOptions) Run() error { } func (p *ProjectInfo) NewAdminPlatformConfigCmd() *cobra.Command { - var o PkatformConfigOptions + var o PlatformConfigOptions var c = &cobra.Command{ Args: cobra.NoArgs, diff --git a/client-programs/pkg/cmd/cluster_portal_create_cmd.go b/client-programs/pkg/cmd/cluster_portal_create_cmd.go index 793b8d2d..9494ceb2 100644 --- a/client-programs/pkg/cmd/cluster_portal_create_cmd.go +++ b/client-programs/pkg/cmd/cluster_portal_create_cmd.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "strings" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -19,6 +20,7 @@ type ClusterConfigViewOptions struct { Password string ThemeName string CookieDomain string + Labels []string } func (o *ClusterConfigViewOptions) Run(isPasswordSet bool) error { @@ -44,7 +46,7 @@ func (o *ClusterConfigViewOptions) Run(isPasswordSet bool) error { // Update the training portal, creating it if necessary. - err = createTrainingPortal(dynamicClient, o.Portal, o.Capacity, o.Password, isPasswordSet, o.ThemeName, o.CookieDomain) + err = createTrainingPortal(dynamicClient, o.Portal, o.Capacity, o.Password, isPasswordSet, o.ThemeName, o.CookieDomain, o.Labels) if err != nil { return err @@ -110,11 +112,18 @@ func (p *ProjectInfo) NewClusterPortalCreateCmd() *cobra.Command { "", "override cookie domain used by training portal and workshops", ) + c.Flags().StringSliceVarP( + &o.Labels, + "labels", + "l", + []string{}, + "label overrides for portal", + ) return c } -func createTrainingPortal(client dynamic.Interface, portal string, capacity uint, password string, isPasswordSet bool, themeName string, cookieDomain string) error { +func createTrainingPortal(client dynamic.Interface, portal string, capacity uint, password string, isPasswordSet bool, themeName string, cookieDomain string, labels []string) error { trainingPortalClient := client.Resource(trainingPortalResource) _, err := trainingPortalClient.Get(context.TODO(), portal, metav1.GetOptions{}) @@ -133,6 +142,21 @@ func createTrainingPortal(client dynamic.Interface, portal string, capacity uint password = randomPassword(12) } + type LabelDetails struct { + Name string `json:"name"` + Value string `json:"value"` + } + + var labelOverrides []LabelDetails + + for _, value := range labels { + parts := strings.SplitN(value, "=", 2) + labelOverrides = append(labelOverrides, LabelDetails{ + Name: parts[0], + Value: parts[1], + }) + } + trainingPortal.SetUnstructuredContent(map[string]interface{}{ "apiVersion": "training.educates.dev/v1beta1", "kind": "TrainingPortal", @@ -174,6 +198,7 @@ func createTrainingPortal(client dynamic.Interface, portal string, capacity uint }{ Domain: cookieDomain, }, + "labels": labelOverrides, }, "workshops": []interface{}{}, }, diff --git a/lookup-service/service/caches/tenants.py b/lookup-service/service/caches/tenants.py index 8bdbd7d4..241e6e1e 100644 --- a/lookup-service/service/caches/tenants.py +++ b/lookup-service/service/caches/tenants.py @@ -31,7 +31,9 @@ def allowed_access_to_cluster(self, cluster: ClusterConfig) -> bool: "metadata": { "name": cluster.name, "uid": cluster.uid, - "labels": cluster.labels, + "labels": { + item["name"]: item["value"] for item in list(cluster.labels) + }, }, } @@ -45,7 +47,7 @@ def allowed_access_to_portal(self, portal: TrainingPortal) -> bool: resource = { "metadata": { "name": portal.name, - "labels": portal.labels, + "labels": {item["name"]: item["value"] for item in list(portal.labels)}, }, } diff --git a/lookup-service/service/routes/clusters.py b/lookup-service/service/routes/clusters.py index 742f8ea7..d902cf60 100644 --- a/lookup-service/service/routes/clusters.py +++ b/lookup-service/service/routes/clusters.py @@ -148,6 +148,52 @@ async def api_get_v1_clusters_portals_environments( return web.json_response(data) +@login_required +@roles_accepted("admin") +async def api_get_v1_clusters_portals_environments_details( + request: web.Request, +) -> web.Response: + """Returns details for the specified environment running on a portal.""" + + cluster_name = request.match_info["cluster"] + portal_name = request.match_info["portal"] + environment_name = request.match_info["environment"] + + service_state = request.app["service_state"] + cluster_database = service_state.cluster_database + + cluster = cluster_database.get_cluster(cluster_name) + + if not cluster: + return web.Response(text="Cluster not available", status=403) + + portal = cluster.get_portal(portal_name) + + if not portal: + return web.Response(text="Portal not available", status=403) + + environment = portal.get_environment(environment_name) + + if not environment: + return web.Response(text="Environment not available", status=403) + + details = { + "name": environment.name, + "generation": environment.generation, + "workshop": environment.workshop, + "title": environment.title, + "description": environment.description, + "labels": environment.labels, + "capacity": environment.capacity, + "reserved": environment.reserved, + "allocated": environment.allocated, + "available": environment.available, + "phase": environment.phase, + } + + return web.json_response(details) + + @login_required @roles_accepted("admin") async def api_get_v1_clusters_portals_environments_sessions( @@ -299,6 +345,10 @@ async def api_get_v1_clusters_portals_environments_users_sessions( "/api/v1/clusters/{cluster}/portals/{portal}/environments", api_get_v1_clusters_portals_environments, ), + web.get( + "/api/v1/clusters/{cluster}/portals/{portal}/environments/{environment}", + api_get_v1_clusters_portals_environments_details, + ), web.get( "/api/v1/clusters/{cluster}/portals/{portal}/environments/{environment}/sessions", api_get_v1_clusters_portals_environments_sessions, diff --git a/lookup-service/service/routes/tenants.py b/lookup-service/service/routes/tenants.py index 97c46173..73ebf4ce 100644 --- a/lookup-service/service/routes/tenants.py +++ b/lookup-service/service/routes/tenants.py @@ -61,6 +61,70 @@ async def api_get_v1_tenants_details(request: web.Request) -> web.Response: return web.json_response(details) +@login_required +@roles_accepted("admin") +async def api_get_v1_tenants_portals(request: web.Request) -> web.Response: + """Returns a list of portals for the specified tenant.""" + + service_state = request.app["service_state"] + tenant_database = service_state.tenant_database + client_database = service_state.client_database + + # Grab tenant name from path parameters. If the client has the tenant role + # they can only access tenants they are mapped to. + + tenant_name = request.match_info["tenant"] + + if not tenant_name: + return web.Response(text="Missing tenant name", status=400) + + client_name = request["client_name"] + client_roles = request["client_roles"] + + # Note that currently "tenant" is not within the allowed roles but leaving + # this code here in case in future we allow access to this endpoint to + # users with the "tenant" role. + + if "tenant" in client_roles: + client = client_database.get_client(client_name) + + if not client: + return web.Response(text="Client not found", status=403) + + if not client.allowed_access_to_tenant(tenant_name): + return web.Response(text="Client access not permitted", status=403) + + # Work out the set of portals accessible for this tenant. + + tenant = tenant_database.get_tenant(tenant_name) + + if not tenant: + return web.Response(text="Tenant not available", status=403) + + accessible_portals = tenant.portals_which_are_accessible() + + # Generate the list of portals available to the user for this tenant. + + data = { + "portals": [ + { + "name": portal.name, + "uid": portal.uid, + "generation": portal.generation, + "labels": portal.labels, + "cluster": portal.cluster.name, + "url": portal.url, + "capacity": portal.capacity, + "allocated": portal.allocated, + "phase": portal.phase, + } + for portal in accessible_portals + ] + } + + return web.json_response(data) + + @login_required @roles_accepted("admin", "tenant") async def api_get_v1_tenants_workshops(request: web.Request) -> web.Response: @@ -123,5 +187,6 @@ async def api_get_v1_tenants_workshops(request: web.Request) -> web.Response: routes = [ web.get("/api/v1/tenants", api_get_v1_tenants), web.get("/api/v1/tenants/{tenant}", api_get_v1_tenants_details), + web.get("/api/v1/tenants/{tenant}/portals", api_get_v1_tenants_portals), web.get("/api/v1/tenants/{tenant}/workshops", api_get_v1_tenants_workshops), ] diff --git a/project-docs/release-notes/version-3.0.0.md b/project-docs/release-notes/version-3.0.0.md index 9901da91..cee5043b 100644 --- a/project-docs/release-notes/version-3.0.0.md +++ b/project-docs/release-notes/version-3.0.0.md @@ -96,6 +96,9 @@ Features Changed so that a users session is not deleted when they take breaks and their computer goes to sleep. +* When using the `educates create-portal` command, labels can now be specified + for the portal via command line options. + Bugs Fixed ----------