Compare commits

..

2 Commits

Author SHA1 Message Date
Jeff McCune
9e60ddbe85 (#175) Add holos generate component cue command
This patch adds a command to generate CUE based holos components from
examples embedded in the executable.  The examples are passed through
the go template rendering engine with values pulled from flags.

Each directory in the embedded filesystem becomes a unique command for
nice tab completion.  The `--name` flag defaults to "example" and is the
resulting component name.

A follow up patch with more flags will set the stage for a Helm
component schematic.

```
holos generate component cue minimal
```

```txt
3:07PM INF component.go:91 generated component version=0.80.2 name=example path=/home/jeff/holos/dev/bare/components/example
```
2024-05-20 15:10:54 -07:00
Jeff McCune
44334fca52 (#175) Fix lint 2024-05-20 12:39:43 -07:00
9 changed files with 280 additions and 95 deletions

View File

@@ -24,17 +24,18 @@ func (b *Builder) Platform(ctx context.Context, cfg *client.Config) (*v1alpha1.P
return nil, errors.Wrap(err)
}
// We only process the first instance, assume the render platform subcommand enforces this.
for idx, instance := range instances {
log.DebugContext(ctx, "cue: building instance", "idx", idx, "dir", instance.Dir)
p, err := b.runPlatform(ctx, instance)
if err != nil {
return nil, errors.Wrap(fmt.Errorf("could not build platform: %w", err))
}
return p, nil
if len(instances) != 1 {
return nil, errors.Wrap(errors.New(fmt.Sprintf("instances length %d must be exactly 1", len(instances))))
}
return nil, errors.Wrap(errors.New("missing platform instance"))
// We only process the first instance, assume the render platform subcommand enforces this.
instance := instances[0]
log.DebugContext(ctx, "cue: building instance", "dir", instance.Dir)
p, err := b.runPlatform(ctx, instance)
if err != nil {
return nil, errors.Wrap(fmt.Errorf("could not build platform: %w", err))
}
return p, nil
}
func (b Builder) runPlatform(ctx context.Context, instance *build.Instance) (*v1alpha1.Platform, error) {

View File

@@ -20,6 +20,7 @@ func New(cfg *holos.Config) *cobra.Command {
cmd.Args = cobra.NoArgs
cmd.AddCommand(NewPlatform(cfg))
cmd.AddCommand(NewComponent())
return cmd
}
@@ -45,3 +46,44 @@ func NewPlatform(cfg *holos.Config) *cobra.Command {
return cmd
}
// NewComponent returns a command to generate a holos component
func NewComponent() *cobra.Command {
cmd := command.New("component")
cmd.Short = "generate a component from an embedded schematic"
cmd.AddCommand(NewCueComponent())
return cmd
}
func NewCueComponent() *cobra.Command {
cmd := command.New("cue")
cmd.Short = "generate a cue component from an embedded schematic"
components := generate.CueComponents()
cmd.Long = fmt.Sprintf("Embedded cue components available to generate:\n\n %s", strings.Join(components, "\n "))
for _, name := range components {
cmd.AddCommand(makeCueCommand(name))
}
return cmd
}
func makeCueCommand(name string) *cobra.Command {
cmd := command.New(name)
cmd.Short = fmt.Sprintf("generate a %s cue component from an embedded schematic", name)
cmd.Args = cobra.NoArgs
cfg := &generate.CueConfig{}
cmd.Flags().AddGoFlagSet(cfg.FlagSet())
cmd.RunE = func(cmd *cobra.Command, args []string) error {
ctx := cmd.Root().Context()
if err := generate.GenerateCueComponent(ctx, name, cfg); err != nil {
return errors.Wrap(err)
}
return nil
}
return cmd
}

View File

@@ -0,0 +1,93 @@
package generate
import (
"bytes"
"context"
"embed"
"flag"
"io/fs"
"log/slog"
"os"
"path/filepath"
"text/template"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/server/middleware/logger"
)
//go:embed all:components
var components embed.FS
// componentsRoot is the root path to copy component cue code from.
const componentsRoot = "components"
// CueConfig represents the config values passed to cue go templates.
type CueConfig struct {
ComponentName string
flagSet *flag.FlagSet
}
func (c *CueConfig) FlagSet() *flag.FlagSet {
if c == nil {
return nil
}
if c.flagSet != nil {
return c.flagSet
}
fs := flag.NewFlagSet("", flag.ContinueOnError)
fs.StringVar(&c.ComponentName, "name", "example", "component name")
c.flagSet = fs
return fs
}
// CueComponents returns a slice of embedded component schematics or nil if there are none.
func CueComponents() []string {
entries, err := fs.ReadDir(components, filepath.Join(componentsRoot, "cue"))
if err != nil {
return nil
}
dirs := make([]string, 0, len(entries))
for _, entry := range entries {
dirs = append(dirs, entry.Name())
}
return dirs
}
// makeRenderFunc makes a template rendering function for embedded files.
func makeRenderFunc(log *slog.Logger, path string, cfg *CueConfig) func([]byte) *bytes.Buffer {
return func(content []byte) *bytes.Buffer {
tmpl, err := template.New(filepath.Base(path)).Parse(string(content))
if err != nil {
log.Error("could not load template", "err", err)
return bytes.NewBuffer(content)
}
var rendered bytes.Buffer
if err := tmpl.Execute(&rendered, cfg); err != nil {
log.Error("could not execute template", "err", err)
return bytes.NewBuffer(content)
}
return &rendered
}
}
// GenerateCueComponent writes the cue code for a component to the local working
// directory.
func GenerateCueComponent(ctx context.Context, name string, cfg *CueConfig) error {
path := filepath.Join(componentsRoot, "cue", name)
dstPath := filepath.Join(getCwd(ctx), cfg.ComponentName)
log := logger.FromContext(ctx).With("name", cfg.ComponentName, "path", dstPath)
log.DebugContext(ctx, "mkdir")
if err := os.MkdirAll(dstPath, os.ModePerm); err != nil {
return errors.Wrap(err)
}
mapper := makeRenderFunc(log, path, cfg)
if err := copyEmbedFS(ctx, components, path, dstPath, mapper); err != nil {
return errors.Wrap(err)
}
log.InfoContext(ctx, "generated component")
return nil
}

View File

@@ -0,0 +1,32 @@
package holos
import v1 "github.com/holos-run/holos/api/v1alpha1"
import "encoding/yaml"
let ComponentName = "{{ .ComponentName }}"
// The BuildPlan represents the kubernetes api objects to manage. CUE returns
// the build plan to the holos CLI for rendering to plain yaml files.
v1.#BuildPlan & {
spec: components: resources: "\(ComponentName)": {
metadata: name: ComponentName
apiObjectMap: OBJECTS.apiObjectMap
}
}
// OBJECTS represents the kubernetes api objects to manage.
let OBJECTS = v1.#APIObjects & {
// Add Kubernetes API Objects to manage here.
apiObjects: ConfigMap: "\(ComponentName)": {
metadata: {
name: ComponentName
namespace: "default"
}
data: platform: yaml.Marshal(PLATFORM)
}
}
// This is an example of how to refer to the Platform model.
let PLATFORM = {
spec: model: _Platform.spec.model
}

View File

@@ -1,97 +1,17 @@
package generate
import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/holos-run/holos/internal/client"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/server/middleware/logger"
platform "github.com/holos-run/holos/service/gen/holos/platform/v1alpha1"
)
//go:embed all:platforms
var platforms embed.FS
// root is the root path to copy platform cue code from.
const root = "platforms"
// Platforms returns a slice of embedded platforms or nil if there are none.
func Platforms() []string {
entries, err := fs.ReadDir(platforms, root)
if err != nil {
return nil
}
dirs := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() && entry.Name() != "cue.mod" {
dirs = append(dirs, entry.Name())
}
}
return dirs
}
// GeneratePlatform writes the cue code for a platform to the local working
// directory.
func GeneratePlatform(ctx context.Context, rpc *client.Client, orgID string, name string) error {
log := logger.FromContext(ctx)
// Check for a valid platform
platformPath := filepath.Join(root, name)
if !dirExists(platforms, platformPath) {
return errors.Wrap(fmt.Errorf("cannot generate: have: [%s] want: %+v", name, Platforms()))
}
// Link the local platform the SaaS platform ID.
rpcPlatforms, err := rpc.Platforms(ctx, orgID)
if err != nil {
return errors.Wrap(err)
}
var rpcPlatform *platform.Platform
for _, p := range rpcPlatforms {
if p.GetName() == name {
rpcPlatform = p
break
}
}
if rpcPlatform == nil {
return errors.Wrap(errors.New("cannot generate: platform not found in the holos server"))
}
// Write the platform data.
data, err := json.MarshalIndent(rpcPlatform, "", " ")
if err != nil {
return errors.Wrap(err)
}
if len(data) > 0 {
data = append(data, '\n')
}
log = log.With("platform_id", rpcPlatform.GetId())
if err := os.WriteFile(client.PlatformMetadataFile, data, 0644); err != nil {
return errors.Wrap(fmt.Errorf("could not write platform metadata: %w", err))
}
log.InfoContext(ctx, "wrote "+client.PlatformMetadataFile, "path", filepath.Join(getCwd(ctx), client.PlatformMetadataFile))
// Copy the cue.mod directory
if err := copyEmbedFS(ctx, platforms, filepath.Join(root, "cue.mod"), "cue.mod"); err != nil {
return errors.Wrap(err)
}
// Copy the named platform
if err := copyEmbedFS(ctx, platforms, platformPath, "."); err != nil {
return errors.Wrap(err)
}
log.InfoContext(ctx, "generated platform "+name, "path", getCwd(ctx))
return nil
}
func dirExists(srcFS embed.FS, path string) bool {
entries, err := fs.ReadDir(srcFS, path)
if err != nil {
@@ -100,7 +20,9 @@ func dirExists(srcFS embed.FS, path string) bool {
return len(entries) > 0
}
func copyEmbedFS(ctx context.Context, srcFS embed.FS, srcPath, dstPath string) error {
// copyEmbedFS copies embedded files from srcPath to dstPath passing the
// contents through mapFunc.
func copyEmbedFS(ctx context.Context, srcFS embed.FS, srcPath, dstPath string, mapFunc func([]byte) *bytes.Buffer) error {
log := logger.FromContext(ctx)
return fs.WalkDir(srcFS, srcPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
@@ -124,7 +46,8 @@ func copyEmbedFS(ctx context.Context, srcFS embed.FS, srcPath, dstPath string) e
if err != nil {
return errors.Wrap(err)
}
if err := os.WriteFile(dstFullPath, data, os.ModePerm); err != nil {
buf := mapFunc(data)
if err := os.WriteFile(dstFullPath, buf.Bytes(), os.ModePerm); err != nil {
return errors.Wrap(err)
}
log.DebugContext(ctx, "wrote", "file", dstFullPath)

View File

@@ -0,0 +1,94 @@
package generate
import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/holos-run/holos/internal/client"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/logger"
platform "github.com/holos-run/holos/service/gen/holos/platform/v1alpha1"
)
//go:embed all:platforms
var platforms embed.FS
// platformsRoot is the root path to copy platform cue code from.
const platformsRoot = "platforms"
// Platforms returns a slice of embedded platforms or nil if there are none.
func Platforms() []string {
entries, err := fs.ReadDir(platforms, platformsRoot)
if err != nil {
return nil
}
dirs := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() && entry.Name() != "cue.mod" {
dirs = append(dirs, entry.Name())
}
}
return dirs
}
// GeneratePlatform writes the cue code for a platform to the local working
// directory.
func GeneratePlatform(ctx context.Context, rpc *client.Client, orgID string, name string) error {
log := logger.FromContext(ctx)
// Check for a valid platform
platformPath := filepath.Join(platformsRoot, name)
if !dirExists(platforms, platformPath) {
return errors.Wrap(fmt.Errorf("cannot generate: have: [%s] want: %+v", name, Platforms()))
}
// Link the local platform the SaaS platform ID.
rpcPlatforms, err := rpc.Platforms(ctx, orgID)
if err != nil {
return errors.Wrap(err)
}
var rpcPlatform *platform.Platform
for _, p := range rpcPlatforms {
if p.GetName() == name {
rpcPlatform = p
break
}
}
if rpcPlatform == nil {
return errors.Wrap(errors.New("cannot generate: platform not found in the holos server"))
}
// Write the platform data.
data, err := json.MarshalIndent(rpcPlatform, "", " ")
if err != nil {
return errors.Wrap(err)
}
if len(data) > 0 {
data = append(data, '\n')
}
log = log.With("platform_id", rpcPlatform.GetId())
if err := os.WriteFile(client.PlatformMetadataFile, data, 0644); err != nil {
return errors.Wrap(fmt.Errorf("could not write platform metadata: %w", err))
}
log.InfoContext(ctx, "wrote "+client.PlatformMetadataFile, "path", filepath.Join(getCwd(ctx), client.PlatformMetadataFile))
// Copy the cue.mod directory
if err := copyEmbedFS(ctx, platforms, filepath.Join(platformsRoot, "cue.mod"), "cue.mod", bytes.NewBuffer); err != nil {
return errors.Wrap(err)
}
// Copy the named platform
if err := copyEmbedFS(ctx, platforms, platformPath, ".", bytes.NewBuffer); err != nil {
return errors.Wrap(err)
}
log.InfoContext(ctx, "generated platform "+name, "path", getCwd(ctx))
return nil
}

View File

@@ -22,7 +22,7 @@ func Platform(ctx context.Context, pf *v1alpha1.Platform, stderr io.Writer) erro
args := []string{"render", "component", "--cluster-name", component.Cluster, component.Path}
result, err := util.RunCmd(ctx, "holos", args...)
if err != nil {
io.Copy(stderr, result.Stderr)
_, _ = io.Copy(stderr, result.Stderr)
return errors.Wrap(fmt.Errorf("could not render component: %w", err))
}
duration := time.Since(start)

View File

@@ -1 +1 @@
80
81

View File

@@ -1 +1 @@
2
0