Skip to content

Commit eb7516f

Browse files
feat(cli): add 'local mirror list' and 'cluster portal export', label educates containers
Add two commands, reimplemented against this branch (the referenced PR educates#871 is a divergent refactor on a different architecture and is not directly portable): - 'educates local mirror list' lists the deployed local registry mirrors as a table (name, URL, username, status, container name). - 'educates cluster portal export' exports a training portal and the workshops it references as sanitised, re-appliable Kubernetes YAML — to stdout as one stream, or with --as-files one file per resource. The --image-repository / --workshop-version options substitute the $(image_repository) / $(workshop_version) variables in the workshops. Give every container Educates creates via the Docker client a consistent set of educates.dev/* labels so they can be discovered, filtered, and cleaned up by label rather than by container name: - New pkg/constants holds the educates.dev/{app,role,mirror,url,username} keys and the registry/mirror/resolver role values. - The registry and mirror containers move to the namespaced keys (mirrors now also carry url/username); the resolver container, which previously had no labels, is labelled app=educates role=resolver. - ListRegistryMirrors and the bulk-delete path share one label filter. The kind node container is created by the kind library and keeps its own io.x-k8s.kind.* labels; it stays discovered by its well-known name.
1 parent 965d8b7 commit eb7516f

11 files changed

Lines changed: 594 additions & 12 deletions

File tree

client-programs/pkg/cmd/cluster_portal_cmd_group.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func (p *ProjectInfo) NewClusterPortalCmdGroup() *cobra.Command {
2525
p.NewClusterPortalOpenCmd(),
2626
p.NewClusterPortalDeleteCmd(),
2727
p.NewClusterPortalPasswordCmd(),
28+
p.NewClusterPortalExportCmd(),
2829
},
2930
},
3031
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
6+
"github.com/pkg/errors"
7+
"github.com/spf13/cobra"
8+
k8serrors "k8s.io/apimachinery/pkg/api/errors"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
11+
"k8s.io/client-go/dynamic"
12+
13+
"github.com/educates/educates-training-platform/client-programs/pkg/cluster"
14+
"github.com/educates/educates-training-platform/client-programs/pkg/utils"
15+
)
16+
17+
var clusterPortalExportExample = `
18+
# Export a training portal and its workshops as YAML to stdout:
19+
educates cluster portal export
20+
21+
# Export a specific training portal:
22+
educates cluster portal export --portal my-portal
23+
24+
# Export the YAML resources as files in a directory:
25+
educates cluster portal export --portal my-portal --as-files ./export
26+
`
27+
28+
type ClusterPortalExportOptions struct {
29+
KubeconfigOptions
30+
Portal string
31+
AsFiles string
32+
Repository string
33+
WorkshopVersion string
34+
}
35+
36+
func (o *ClusterPortalExportOptions) Run() error {
37+
if o.Portal == "" {
38+
o.Portal = "educates-cli"
39+
}
40+
41+
clusterConfig, err := cluster.NewClusterConfigIfAvailable(o.Kubeconfig, o.Context)
42+
if err != nil {
43+
return err
44+
}
45+
46+
dynamicClient, err := clusterConfig.GetDynamicClient()
47+
if err != nil {
48+
return errors.Wrap(err, "unable to create Kubernetes client")
49+
}
50+
51+
documents, err := exportTrainingPortalDocuments(dynamicClient, o.Portal, o.Repository, o.WorkshopVersion)
52+
if err != nil {
53+
return err
54+
}
55+
56+
if o.AsFiles != "" {
57+
return utils.WriteExportedDocuments(o.AsFiles, documents)
58+
}
59+
60+
return utils.PrintExportedDocuments(documents)
61+
}
62+
63+
// exportTrainingPortalDocuments fetches the training portal and each
64+
// workshop it references, sanitizes them for re-apply, and returns them as
65+
// named YAML documents (trainingportal.yaml first, then one per workshop).
66+
func exportTrainingPortalDocuments(client dynamic.Interface, portal, repository, workshopVersion string) ([]utils.ExportedYAMLDocument, error) {
67+
trainingPortalClient := client.Resource(trainingPortalResource)
68+
69+
trainingPortal, err := trainingPortalClient.Get(context.TODO(), portal, metav1.GetOptions{})
70+
if err != nil {
71+
if k8serrors.IsNotFound(err) {
72+
return nil, errors.Errorf("training portal %q does not exist", portal)
73+
}
74+
return nil, errors.Wrapf(err, "unable to fetch training portal %q in cluster", portal)
75+
}
76+
77+
workshopNames, err := extractWorkshopNamesFromTrainingPortal(trainingPortal)
78+
if err != nil {
79+
return nil, err
80+
}
81+
82+
documents := make([]utils.ExportedYAMLDocument, 0, len(workshopNames)+1)
83+
84+
portalData, err := utils.RenderResourceAsYAMLDocument(utils.SanitizeTrainingPortalResourceForExport(utils.SanitizeResourceForExport(trainingPortal)))
85+
if err != nil {
86+
return nil, errors.Wrapf(err, "unable to generate YAML for training portal %q", trainingPortal.GetName())
87+
}
88+
documents = append(documents, utils.ExportedYAMLDocument{Name: "trainingportal.yaml", Data: portalData})
89+
90+
workshopsClient := client.Resource(workshopResource)
91+
92+
for _, name := range workshopNames {
93+
workshop, err := workshopsClient.Get(context.TODO(), name, metav1.GetOptions{})
94+
if err != nil {
95+
if k8serrors.IsNotFound(err) {
96+
return nil, errors.Errorf("workshop %q referenced by training portal %q does not exist", name, portal)
97+
}
98+
return nil, errors.Wrapf(err, "unable to fetch workshop %q in cluster", name)
99+
}
100+
101+
workshopData, err := utils.RenderResourceAsYAMLDocument(utils.SanitizeWorkshopResourceForExport(utils.SanitizeResourceForExport(workshop), &utils.WorkshopResourceExportConfig{
102+
Repository: repository,
103+
WorkshopVersion: workshopVersion,
104+
}))
105+
if err != nil {
106+
return nil, errors.Wrapf(err, "unable to generate YAML for workshop %q", name)
107+
}
108+
109+
documents = append(documents, utils.ExportedYAMLDocument{Name: name + ".yaml", Data: workshopData})
110+
}
111+
112+
return documents, nil
113+
}
114+
115+
// extractWorkshopNamesFromTrainingPortal returns the de-duplicated workshop
116+
// names referenced in a training portal's spec.workshops.
117+
func extractWorkshopNamesFromTrainingPortal(trainingPortal *unstructured.Unstructured) ([]string, error) {
118+
workshops, _, err := unstructured.NestedSlice(trainingPortal.Object, "spec", "workshops")
119+
if err != nil {
120+
return nil, errors.Wrap(err, "unable to retrieve workshops from training portal")
121+
}
122+
123+
names := []string{}
124+
seen := map[string]struct{}{}
125+
126+
for _, item := range workshops {
127+
object, ok := item.(map[string]interface{})
128+
if !ok {
129+
return nil, errors.Errorf("invalid workshop reference in training portal %q", trainingPortal.GetName())
130+
}
131+
132+
name, ok := object["name"].(string)
133+
if !ok || name == "" {
134+
return nil, errors.Errorf("invalid workshop reference in training portal %q", trainingPortal.GetName())
135+
}
136+
137+
if _, exists := seen[name]; exists {
138+
continue
139+
}
140+
141+
seen[name] = struct{}{}
142+
names = append(names, name)
143+
}
144+
145+
return names, nil
146+
}
147+
148+
func (p *ProjectInfo) NewClusterPortalExportCmd() *cobra.Command {
149+
var o ClusterPortalExportOptions
150+
151+
var c = &cobra.Command{
152+
Args: cobra.NoArgs,
153+
Use: "export",
154+
Short: "Export portal and its workshops from Kubernetes",
155+
Example: clusterPortalExportExample,
156+
RunE: func(_ *cobra.Command, _ []string) error { return o.Run() },
157+
}
158+
159+
c.Flags().StringVar(
160+
&o.Kubeconfig,
161+
"kubeconfig",
162+
"",
163+
"kubeconfig file to use instead of $KUBECONFIG or $HOME/.kube/config",
164+
)
165+
c.Flags().StringVar(
166+
&o.Context,
167+
"context",
168+
"",
169+
"Context to use from Kubeconfig",
170+
)
171+
c.Flags().StringVarP(
172+
&o.Portal,
173+
"portal",
174+
"p",
175+
"educates-cli",
176+
"name to be used for training portal and workshop name prefixes",
177+
)
178+
c.Flags().StringVar(
179+
&o.AsFiles,
180+
"as-files",
181+
"",
182+
"write YAML resources as files in the target directory instead of stdout",
183+
)
184+
c.Flags().StringVar(
185+
&o.Repository,
186+
"image-repository",
187+
"localhost:5001",
188+
"the address of the image repository",
189+
)
190+
c.Flags().StringVar(
191+
&o.WorkshopVersion,
192+
"workshop-version",
193+
"latest",
194+
"version of the workshop being exported",
195+
)
196+
197+
return c
198+
}

client-programs/pkg/cmd/local_mirror_cmd_group.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func (p *ProjectInfo) NewLocalMirrorCmdGroup() *cobra.Command {
2121
Commands: []*cobra.Command{
2222
p.NewLocalMirrorDeployCmd(),
2323
p.NewLocalMirrorDeleteCmd(),
24+
p.NewLocalMirrorListCmd(),
2425
},
2526
},
2627
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/pkg/errors"
7+
"github.com/spf13/cobra"
8+
9+
"github.com/educates/educates-training-platform/client-programs/pkg/registry"
10+
"github.com/educates/educates-training-platform/client-programs/pkg/utils"
11+
)
12+
13+
var localMirrorListExample = `
14+
# List the deployed local image registry mirrors:
15+
educates local mirror list
16+
`
17+
18+
func (p *ProjectInfo) NewLocalMirrorListCmd() *cobra.Command {
19+
var c = &cobra.Command{
20+
Args: cobra.NoArgs,
21+
Use: "list",
22+
Short: "Lists the local image registry mirrors",
23+
Example: localMirrorListExample,
24+
RunE: func(_ *cobra.Command, _ []string) error {
25+
mirrors, err := registry.ListRegistryMirrors()
26+
if err != nil {
27+
return errors.Wrap(err, "failed to list registry mirrors")
28+
}
29+
30+
if len(mirrors) == 0 {
31+
fmt.Println("No mirrors found.")
32+
return nil
33+
}
34+
35+
rows := make([][]string, 0, len(mirrors))
36+
for _, m := range mirrors {
37+
rows = append(rows, []string{m.Name, m.URL, m.Username, m.Status, m.ContainerName})
38+
}
39+
40+
fmt.Println(utils.PrintTable([]string{"NAME", "URL", "USERNAME", "STATUS", "CONTAINER_NAME"}, rows))
41+
42+
return nil
43+
},
44+
}
45+
46+
return c
47+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Package constants holds shared constant values used across the CLI.
2+
package constants
3+
4+
const (
5+
// Container label keys applied to the Docker containers Educates
6+
// creates directly (the local registry, registry mirrors, and the
7+
// DNS resolver) so they can be discovered, filtered, and cleaned up
8+
// by label rather than by container name.
9+
EducatesContainersAppLabelKey = "educates.dev/app"
10+
EducatesContainersRoleLabelKey = "educates.dev/role"
11+
EducatesContainersMirrorLabelKey = "educates.dev/mirror"
12+
EducatesContainersURLLabelKey = "educates.dev/url"
13+
EducatesContainersUsernameLabelKey = "educates.dev/username"
14+
15+
// Container label values.
16+
EducatesContainersAppLabel = "educates"
17+
EducatesContainersRegistryRoleLabel = "registry"
18+
EducatesContainersMirrorRoleLabel = "mirror"
19+
EducatesContainersResolverRoleLabel = "resolver"
20+
)

0 commit comments

Comments
 (0)