mirror of
https://github.com/holos-run/holos.git
synced 2026-03-19 16:54:58 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e95a2812e | ||
|
|
54efe3e24a | ||
|
|
f693f049f4 | ||
|
|
85238710ac | ||
|
|
3ec62d272e | ||
|
|
49afb44fd4 | ||
|
|
a023f135ab | ||
|
|
c6a3a5d689 |
@@ -74,6 +74,7 @@
|
||||
"creds",
|
||||
"crossplane",
|
||||
"crunchydata",
|
||||
"ctxt",
|
||||
"cuecontext",
|
||||
"cuelang",
|
||||
"customresourcedefinition",
|
||||
|
||||
@@ -303,6 +303,10 @@ type Component struct {
|
||||
// Path represents the path of the component relative to the platform root.
|
||||
// Injected as the tag variable "holos_component_path".
|
||||
Path string `json:"path" yaml:"path"`
|
||||
// Instances represents additional cue instance paths to unify with Path.
|
||||
// Useful to unify data files into a component BuildPlan. Added in holos
|
||||
// 0.101.7.
|
||||
Instances []Instance `json:"instances,omitempty" yaml:"instances,omitempty"`
|
||||
// WriteTo represents the holos render component --write-to flag. If empty,
|
||||
// the default value for the --write-to flag is used.
|
||||
WriteTo string `json:"writeTo,omitempty" yaml:"writeTo,omitempty"`
|
||||
@@ -319,3 +323,27 @@ type Component struct {
|
||||
// `cli.holos.run/description` to customize the log message of each BuildPlan.
|
||||
Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// Instance represents a data instance to unify with the configuration.
|
||||
//
|
||||
// Useful to unify json and yaml files with cue configuration files for
|
||||
// integration with other tools. For example, executing holos render platform
|
||||
// from a pull request workflow after [Kargo] executes the [yaml update] and
|
||||
// [git wait for pr] promotion steps.
|
||||
//
|
||||
// [Kargo]: https://docs.kargo.io/
|
||||
// [yaml update]: https://docs.kargo.io/references/promotion-steps#yaml-update
|
||||
// [git wait for pr]: https://docs.kargo.io/references/promotion-steps#git-wait-for-pr
|
||||
type Instance struct {
|
||||
// Kind is a discriminator.
|
||||
Kind string `json:"kind" yaml:"kind" cue:"\"ExtractYAML\""`
|
||||
// Ignored unless kind is ExtractYAML.
|
||||
ExtractYAML ExtractYAML `json:"extractYAML,omitempty" yaml:"extractYAML,omitempty"`
|
||||
}
|
||||
|
||||
// ExtractYAML represents a cue data instance encoded as yaml or json. If Path
|
||||
// refers to a directory all files in the directory are extracted
|
||||
// non-recursively. Otherwise, path must refer to a file.
|
||||
type ExtractYAML struct {
|
||||
Path string `json:"path" yaml:"path"`
|
||||
}
|
||||
|
||||
63
cmd/cmd.go
Normal file
63
cmd/cmd.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime/pprof"
|
||||
"runtime/trace"
|
||||
|
||||
"github.com/holos-run/holos/internal/cli"
|
||||
"github.com/holos-run/holos/internal/holos"
|
||||
)
|
||||
|
||||
// MakeMain makes a main function for the cli or tests.
|
||||
func MakeMain(options ...holos.Option) func() int {
|
||||
return func() (exitCode int) {
|
||||
cfg := holos.New(options...)
|
||||
slog.SetDefault(cfg.Logger())
|
||||
ctx := context.Background()
|
||||
|
||||
if format := os.Getenv("HOLOS_CPU_PROFILE"); format != "" {
|
||||
f, _ := os.Create(fmt.Sprintf(format, os.Getppid(), os.Getpid()))
|
||||
err := pprof.StartCPUProfile(f)
|
||||
defer func() {
|
||||
pprof.StopCPUProfile()
|
||||
f.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return cli.HandleError(ctx, err, cfg)
|
||||
}
|
||||
}
|
||||
defer memProfile(ctx, cfg)
|
||||
|
||||
if format := os.Getenv("HOLOS_TRACE"); format != "" {
|
||||
f, _ := os.Create(fmt.Sprintf(format, os.Getppid(), os.Getpid()))
|
||||
err := trace.Start(f)
|
||||
defer func() {
|
||||
trace.Stop()
|
||||
f.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return cli.HandleError(ctx, err, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
feature := &holos.EnvFlagger{}
|
||||
if err := cli.New(cfg, feature).ExecuteContext(ctx); err != nil {
|
||||
return cli.HandleError(ctx, err, cfg)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func memProfile(ctx context.Context, cfg *holos.Config) {
|
||||
if format := os.Getenv("HOLOS_MEM_PROFILE"); format != "" {
|
||||
f, _ := os.Create(fmt.Sprintf(format, os.Getppid(), os.Getpid()))
|
||||
defer f.Close()
|
||||
if err := pprof.WriteHeapProfile(f); err != nil {
|
||||
_ = cli.HandleError(ctx, err, cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/holos-run/holos/internal/cli"
|
||||
"github.com/holos-run/holos/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(cli.MakeMain()())
|
||||
os.Exit(cmd.MakeMain()())
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
"testing"
|
||||
|
||||
cue "cuelang.org/go/cmd/cue/cmd"
|
||||
"github.com/holos-run/holos/internal/cli"
|
||||
"github.com/holos-run/holos/cmd"
|
||||
"github.com/rogpeppe/go-internal/testscript"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(testscript.RunMain(m, map[string]func() int{
|
||||
"holos": cli.MakeMain(),
|
||||
"holos": cmd.MakeMain(),
|
||||
"cue": cue.Main,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -22,12 +22,14 @@ Package core contains schemas for a [Platform](<#Platform>) and [BuildPlan](<#Bu
|
||||
- [type Chart](<#Chart>)
|
||||
- [type Command](<#Command>)
|
||||
- [type Component](<#Component>)
|
||||
- [type ExtractYAML](<#ExtractYAML>)
|
||||
- [type File](<#File>)
|
||||
- [type FileContent](<#FileContent>)
|
||||
- [type FileContentMap](<#FileContentMap>)
|
||||
- [type FilePath](<#FilePath>)
|
||||
- [type Generator](<#Generator>)
|
||||
- [type Helm](<#Helm>)
|
||||
- [type Instance](<#Instance>)
|
||||
- [type InternalLabel](<#InternalLabel>)
|
||||
- [type Join](<#Join>)
|
||||
- [type Kind](<#Kind>)
|
||||
@@ -169,6 +171,10 @@ type Component struct {
|
||||
// Path represents the path of the component relative to the platform root.
|
||||
// Injected as the tag variable "holos_component_path".
|
||||
Path string `json:"path" yaml:"path"`
|
||||
// Instances represents additional cue instance paths to unify with Path.
|
||||
// Useful to unify data files into a component BuildPlan. Added in holos
|
||||
// 0.101.7.
|
||||
Instances []Instance `json:"instances,omitempty" yaml:"instances,omitempty"`
|
||||
// WriteTo represents the holos render component --write-to flag. If empty,
|
||||
// the default value for the --write-to flag is used.
|
||||
WriteTo string `json:"writeTo,omitempty" yaml:"writeTo,omitempty"`
|
||||
@@ -187,6 +193,17 @@ type Component struct {
|
||||
}
|
||||
```
|
||||
|
||||
<a name="ExtractYAML"></a>
|
||||
## type ExtractYAML {#ExtractYAML}
|
||||
|
||||
ExtractYAML represents a cue data instance encoded as yaml or json. If Path refers to a directory all files in the directory are extracted non\-recursively. Otherwise, path must refer to a file.
|
||||
|
||||
```go
|
||||
type ExtractYAML struct {
|
||||
Path string `json:"path" yaml:"path"`
|
||||
}
|
||||
```
|
||||
|
||||
<a name="File"></a>
|
||||
## type File {#File}
|
||||
|
||||
@@ -279,6 +296,22 @@ type Helm struct {
|
||||
}
|
||||
```
|
||||
|
||||
<a name="Instance"></a>
|
||||
## type Instance {#Instance}
|
||||
|
||||
Instance represents a data instance to unify with the configuration.
|
||||
|
||||
Useful to unify json and yaml files with cue configuration files for integration with other tools. For example, executing holos render platform from a pull request workflow after [Kargo](<https://docs.kargo.io/>) executes the [yaml update](<https://docs.kargo.io/references/promotion-steps#yaml-update>) and [git wait for pr](<https://docs.kargo.io/references/promotion-steps#git-wait-for-pr>) promotion steps.
|
||||
|
||||
```go
|
||||
type Instance struct {
|
||||
// Kind is a discriminator.
|
||||
Kind string `json:"kind" yaml:"kind" cue:"\"ExtractYAML\""`
|
||||
// Ignored unless kind is ExtractYAML.
|
||||
ExtractYAML ExtractYAML `json:"extractYAML,omitempty" yaml:"extractYAML,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
<a name="InternalLabel"></a>
|
||||
## type InternalLabel {#InternalLabel}
|
||||
|
||||
|
||||
57
doc/md/topics/comparison.mdx
Normal file
57
doc/md/topics/comparison.mdx
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
description: Holos compared to other tools
|
||||
sidebar_label: Comparison
|
||||
slug: comparison
|
||||
sidebar_position: 40
|
||||
---
|
||||
|
||||
{/* cspell:ignore Prodan, rollouts */}
|
||||
|
||||
# Holos compared to other tools
|
||||
|
||||
## Timoni
|
||||
|
||||
Holos and Timoni both aim to solve similar problems but approach them at
|
||||
different levels of the stack.
|
||||
|
||||
Timoni focuses on managing applications by evaluating [CUE] stored in OCI
|
||||
containers. Its creator, Stephan Prodan, envisions a controller that applies the
|
||||
resulting manifests. In this process, Timoni defers to [Flux] for managing Helm
|
||||
charts within the cluster.
|
||||
|
||||
In contrast, Holos implements the [Rendered Manifests Pattern] and takes a
|
||||
different approach, particularly in how it handles [Helm] charts. Like
|
||||
[ArgoCD], Holos renders Helm charts into manifests using the `helm template`
|
||||
command in its rendering pipeline. Holos differs from Timoni in several important
|
||||
ways:
|
||||
|
||||
1. **Separation of Responsibilities:** Holos stops short of applying
|
||||
rendered manifests to a cluster, leaving that task to existing tools like
|
||||
[ArgoCD], [Flux], or even basic `kubectl apply` commands.
|
||||
|
||||
2. **Ecosystem Integration:** By focusing solely on rendering Kubernetes
|
||||
manifests, Holos creates space for other tools to handle deployment and
|
||||
management. For instance, Holos integrates seamlessly with [Kargo] for
|
||||
progressive rollouts, as [Kargo] operates between Holos and the Kubernetes API.
|
||||
This approach ensures that you're not locked into any specific tool and can
|
||||
choose the best solution for each task.
|
||||
|
||||
3. **Platform Integration:** Holos focuses on integrating multiple Components
|
||||
into a larger Platform. In Holos terminology, a Component refers to a wrapper
|
||||
for [Helm] charts, [Kustomize] bases, or raw YAML files, integrated into the
|
||||
rendering pipeline through [CUE]. A Platform represents the full combination of
|
||||
these components.
|
||||
|
||||
4. **Explicit Rendering Pipeline:** Holos emphasizes flexibility in its
|
||||
rendering pipeline. The system allows any tool that generates Kubernetes
|
||||
manifests to be wrapped in a Generator, which can then feed into existing
|
||||
transformers like [Kustomize]. This explicit separation makes Holos highly
|
||||
adaptable for different workflows.
|
||||
|
||||
[Kargo]: https://kargo.io/
|
||||
[Flux]: https://fluxcd.io
|
||||
[Helm]: https://helm.sh
|
||||
[ArgoCD]: https://argoproj.github.io/cd/
|
||||
[Kustomize]: https://kustomize.io/
|
||||
[CUE]: https://cuelang.org/
|
||||
[Rendered Manifests Pattern]: https://akuity.io/blog/the-rendered-manifests-pattern
|
||||
@@ -14,3 +14,7 @@
|
||||
/docs/local-cluster/ /docs/v1alpha5/topics/local-cluster/ 301
|
||||
/docs/guides/helm /docs/v1alpha5/tutorial/helm-values/ 301
|
||||
/docs/guides/helm/ /docs/v1alpha5/tutorial/helm-values/ 301
|
||||
/docs/kargo /docs/v1alpha5/topics/kargo/ 301
|
||||
/docs/kargo/ /docs/v1alpha5/topics/kargo/ 301
|
||||
/docs/comparison /docs/v1alpha5/topics/comparison/ 301
|
||||
/docs/comparison/ /docs/v1alpha5/topics/comparison/ 301
|
||||
|
||||
@@ -5,17 +5,67 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"cuelang.org/go/cue"
|
||||
"cuelang.org/go/cue/cuecontext"
|
||||
"cuelang.org/go/cue/load"
|
||||
"cuelang.org/go/encoding/yaml"
|
||||
"github.com/holos-run/holos/internal/errors"
|
||||
"github.com/holos-run/holos/internal/holos"
|
||||
"github.com/holos-run/holos/internal/util"
|
||||
)
|
||||
|
||||
func LoadInstance(path string, tags []string) (*Instance, error) {
|
||||
// ExtractYAML extracts yaml encoded data from file paths. The data is unified
|
||||
// into one [cue.Value]. If a path element is a directory, all files in the
|
||||
// directory are loaded non-recursively.
|
||||
//
|
||||
// Attribution: https://github.com/cue-lang/cue/issues/3504
|
||||
func ExtractYAML(ctxt *cue.Context, filepaths []string) (cue.Value, error) {
|
||||
value := ctxt.CompileString("")
|
||||
files := make([]string, 0, 10*len(filepaths))
|
||||
|
||||
for _, path := range filepaths {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return value, errors.Wrap(err)
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
files = append(files, path)
|
||||
continue
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return value, errors.Wrap(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
files = append(files, filepath.Join(path, entry.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
f, err := yaml.Extract(file, nil)
|
||||
if err != nil {
|
||||
return value, errors.Wrap(err)
|
||||
}
|
||||
value = value.Unify(ctxt.BuildFile(f))
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// LoadInstance loads the cue configuration instance at path. External data
|
||||
// file paths are loaded by calling [ExtractYAML] providing filepaths. The
|
||||
// extracted data values are unified with the platform configuration [cue.Value]
|
||||
// in the returned [Instance].
|
||||
func LoadInstance(path string, filepaths []string, tags []string) (*Instance, error) {
|
||||
root, leaf, err := util.FindRootLeaf(path)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err)
|
||||
@@ -26,20 +76,26 @@ func LoadInstance(path string, tags []string) (*Instance, error) {
|
||||
ModuleRoot: root,
|
||||
Tags: tags,
|
||||
}
|
||||
ctxt := cuecontext.New()
|
||||
|
||||
ctx := cuecontext.New()
|
||||
|
||||
instances := load.Instances([]string{leaf}, cfg)
|
||||
values, err := ctx.BuildInstances(instances)
|
||||
bis := load.Instances([]string{path}, cfg)
|
||||
values, err := ctxt.BuildInstances(bis)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err)
|
||||
}
|
||||
|
||||
value, err := ExtractYAML(ctxt, filepaths)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err)
|
||||
}
|
||||
// TODO: https://cuelang.org/docs/howto/place-data-go-api/
|
||||
value = value.Unify(values[0])
|
||||
|
||||
inst := &Instance{
|
||||
path: leaf,
|
||||
ctx: ctx,
|
||||
ctx: ctxt,
|
||||
cfg: cfg,
|
||||
value: values[0],
|
||||
value: value,
|
||||
}
|
||||
|
||||
return inst, nil
|
||||
|
||||
@@ -107,6 +107,20 @@ func (c *Component) Path() string {
|
||||
return util.DotSlash(c.Component.Path)
|
||||
}
|
||||
|
||||
// ExtractYAML returns the path values for the --extract-yaml command line flag.
|
||||
func (c *Component) ExtractYAML() ([]string, error) {
|
||||
if c == nil {
|
||||
return nil, nil
|
||||
}
|
||||
instances := make([]string, 0, len(c.Component.Instances))
|
||||
for _, instance := range c.Component.Instances {
|
||||
if instance.Kind == "ExtractYAML" {
|
||||
instances = append(instances, instance.ExtractYAML.Path)
|
||||
}
|
||||
}
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
var _ holos.BuildPlan = &BuildPlan{}
|
||||
var _ task = generatorTask{}
|
||||
var _ task = transformersTask{}
|
||||
|
||||
@@ -1,110 +1 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime/pprof"
|
||||
"runtime/trace"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
cue "cuelang.org/go/cue/errors"
|
||||
"github.com/holos-run/holos/internal/errors"
|
||||
"github.com/holos-run/holos/internal/holos"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
)
|
||||
|
||||
func memProfile(ctx context.Context, cfg *holos.Config) {
|
||||
if format := os.Getenv("HOLOS_MEM_PROFILE"); format != "" {
|
||||
f, _ := os.Create(fmt.Sprintf(format, os.Getppid(), os.Getpid()))
|
||||
defer f.Close()
|
||||
if err := pprof.WriteHeapProfile(f); err != nil {
|
||||
_ = HandleError(ctx, err, cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MakeMain makes a main function for the cli or tests.
|
||||
func MakeMain(options ...holos.Option) func() int {
|
||||
return func() (exitCode int) {
|
||||
cfg := holos.New(options...)
|
||||
slog.SetDefault(cfg.Logger())
|
||||
ctx := context.Background()
|
||||
|
||||
if format := os.Getenv("HOLOS_CPU_PROFILE"); format != "" {
|
||||
f, _ := os.Create(fmt.Sprintf(format, os.Getppid(), os.Getpid()))
|
||||
err := pprof.StartCPUProfile(f)
|
||||
defer func() {
|
||||
pprof.StopCPUProfile()
|
||||
f.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return HandleError(ctx, err, cfg)
|
||||
}
|
||||
}
|
||||
defer memProfile(ctx, cfg)
|
||||
|
||||
if format := os.Getenv("HOLOS_TRACE"); format != "" {
|
||||
f, _ := os.Create(fmt.Sprintf(format, os.Getppid(), os.Getpid()))
|
||||
err := trace.Start(f)
|
||||
defer func() {
|
||||
trace.Stop()
|
||||
f.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return HandleError(ctx, err, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
feature := &holos.EnvFlagger{}
|
||||
if err := New(cfg, feature).ExecuteContext(ctx); err != nil {
|
||||
return HandleError(ctx, err, cfg)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// HandleError is the top level error handler that unwraps and logs errors.
|
||||
func HandleError(ctx context.Context, err error, hc *holos.Config) (exitCode int) {
|
||||
// Connect errors have codes, log them.
|
||||
log := hc.NewTopLevelLogger().With("code", connect.CodeOf(err))
|
||||
var cueErr cue.Error
|
||||
var errAt *errors.ErrorAt
|
||||
|
||||
if errors.As(err, &errAt) {
|
||||
loc := errAt.Source.Loc()
|
||||
err2 := errAt.Unwrap()
|
||||
log.ErrorContext(ctx, fmt.Sprintf("could not run: %s at %s", err2, loc), "err", err2, "loc", loc)
|
||||
} else {
|
||||
log.ErrorContext(ctx, fmt.Sprintf("could not run: %s", err), "err", err)
|
||||
}
|
||||
|
||||
// cue errors are bundled up as a list and refer to multiple files / lines.
|
||||
if errors.As(err, &cueErr) {
|
||||
msg := cue.Details(cueErr, nil)
|
||||
if _, err := fmt.Fprint(hc.Stderr(), msg); err != nil {
|
||||
log.ErrorContext(ctx, "could not write CUE error details: "+err.Error(), "err", err)
|
||||
}
|
||||
}
|
||||
// connect errors have details and codes.
|
||||
// Refer to https://connectrpc.com/docs/go/errors
|
||||
if connectErr := new(connect.Error); errors.As(err, &connectErr) {
|
||||
for _, detail := range connectErr.Details() {
|
||||
msg, valueErr := detail.Value()
|
||||
if valueErr != nil {
|
||||
log.WarnContext(ctx, "could not decode error detail", "err", err, "type", detail.Type(), "note", "this usually means we don't have the schema for the protobuf message type")
|
||||
continue
|
||||
}
|
||||
if info, ok := msg.(*errdetails.ErrorInfo); ok {
|
||||
logDetail := log.With("reason", info.GetReason(), "domain", info.GetDomain())
|
||||
for k, v := range info.GetMetadata() {
|
||||
logDetail = logDetail.With(k, v)
|
||||
}
|
||||
logDetail.ErrorContext(ctx, info.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ func newPlatform(cfg *holos.Config, feature holos.Flagger) *cobra.Command {
|
||||
cmd.Flags().IntVar(&concurrency, "concurrency", runtime.NumCPU(), "number of components to render concurrently")
|
||||
var platform string
|
||||
cmd.Flags().StringVar(&platform, "platform", "./platform", "platform directory path")
|
||||
var extractYAMLs holos.StringSlice
|
||||
cmd.Flags().Var(&extractYAMLs, "extract-yaml", "data file paths to extract and unify with the platform config")
|
||||
var selector holos.Selector
|
||||
cmd.Flags().VarP(&selector, "selector", "l", "label selector (e.g. label==string,label!=string)")
|
||||
tagMap := make(holos.TagMap)
|
||||
@@ -57,7 +59,7 @@ func newPlatform(cfg *holos.Config, feature holos.Flagger) *cobra.Command {
|
||||
log.WarnContext(ctx, fmt.Sprintf(msg, platform))
|
||||
}
|
||||
|
||||
inst, err := builder.LoadInstance(platform, tagMap.Tags())
|
||||
inst, err := builder.LoadInstance(platform, extractYAMLs, tagMap.Tags())
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
@@ -107,12 +109,14 @@ func newComponent(cfg *holos.Config, feature holos.Flagger) *cobra.Command {
|
||||
cmd.Flags().VarP(&tagMap, "inject", "t", tagHelp)
|
||||
var concurrency int
|
||||
cmd.Flags().IntVar(&concurrency, "concurrency", runtime.NumCPU(), "number of concurrent build steps")
|
||||
var extractYAMLs holos.StringSlice
|
||||
cmd.Flags().Var(&extractYAMLs, "extract-yaml", "data file paths to extract and unify with the platform config")
|
||||
|
||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Root().Context()
|
||||
path := args[0]
|
||||
|
||||
inst, err := builder.LoadInstance(path, tagMap.Tags())
|
||||
inst, err := builder.LoadInstance(path, extractYAMLs, tagMap.Tags())
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
@@ -146,7 +150,11 @@ func makeComponentRenderFunc(w io.Writer, prefixArgs, cliTags []string) func(con
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
args := make([]string, 0, 10+len(prefixArgs)+(len(tags)*2))
|
||||
filepaths, err := component.ExtractYAML()
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
args := make([]string, 0, 10+len(prefixArgs)+(len(tags)*2+len(filepaths)*2))
|
||||
args = append(args, prefixArgs...)
|
||||
args = append(args, "render", "component")
|
||||
for _, tag := range cliTags {
|
||||
@@ -155,6 +163,9 @@ func makeComponentRenderFunc(w io.Writer, prefixArgs, cliTags []string) func(con
|
||||
for _, tag := range tags {
|
||||
args = append(args, "--inject", tag)
|
||||
}
|
||||
for _, path := range filepaths {
|
||||
args = append(args, "--extract-yaml", path)
|
||||
}
|
||||
args = append(args, component.Path())
|
||||
if _, err := util.RunCmdA(ctx, w, "holos", args...); err != nil {
|
||||
return errors.Format("could not render component: %w", err)
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
|
||||
"github.com/holos-run/holos/version"
|
||||
|
||||
"github.com/holos-run/holos/internal/errors"
|
||||
"github.com/holos-run/holos/internal/holos"
|
||||
"github.com/holos-run/holos/internal/logger"
|
||||
"github.com/holos-run/holos/internal/server"
|
||||
@@ -28,7 +32,8 @@ import (
|
||||
"github.com/holos-run/holos/internal/cli/token"
|
||||
"github.com/holos-run/holos/internal/cli/txtar"
|
||||
|
||||
cue "cuelang.org/go/cmd/cue/cmd"
|
||||
cueCmd "cuelang.org/go/cmd/cue/cmd"
|
||||
cue_errors "cuelang.org/go/cue/errors"
|
||||
)
|
||||
|
||||
//go:embed help.txt
|
||||
@@ -119,7 +124,7 @@ func newOrgCmd(feature holos.Flagger) (cmd *cobra.Command) {
|
||||
|
||||
func newCueCmd() (cmd *cobra.Command) {
|
||||
// Get a handle on the cue root command fields.
|
||||
root, _ := cue.New([]string{})
|
||||
root, _ := cueCmd.New([]string{})
|
||||
// Copy the fields to our embedded command.
|
||||
cmd = command.New("cue")
|
||||
cmd.Short = root.Short
|
||||
@@ -130,8 +135,52 @@ func newCueCmd() (cmd *cobra.Command) {
|
||||
|
||||
// We do it this way so we handle errors correctly.
|
||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
cueRootCommand, _ := cue.New(args)
|
||||
cueRootCommand, _ := cueCmd.New(args)
|
||||
return cueRootCommand.Run(cmd.Root().Context())
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// HandleError is the top level error handler that unwraps and logs errors.
|
||||
func HandleError(ctx context.Context, err error, hc *holos.Config) (exitCode int) {
|
||||
// Connect errors have codes, log them.
|
||||
log := hc.NewTopLevelLogger().With("code", connect.CodeOf(err))
|
||||
var cueErr cue_errors.Error
|
||||
var errAt *errors.ErrorAt
|
||||
|
||||
if errors.As(err, &errAt) {
|
||||
loc := errAt.Source.Loc()
|
||||
err2 := errAt.Unwrap()
|
||||
log.ErrorContext(ctx, fmt.Sprintf("could not run: %s at %s", err2, loc), "err", err2, "loc", loc)
|
||||
} else {
|
||||
log.ErrorContext(ctx, fmt.Sprintf("could not run: %s", err), "err", err)
|
||||
}
|
||||
|
||||
// cue errors are bundled up as a list and refer to multiple files / lines.
|
||||
if errors.As(err, &cueErr) {
|
||||
msg := cue_errors.Details(cueErr, nil)
|
||||
if _, err := fmt.Fprint(hc.Stderr(), msg); err != nil {
|
||||
log.ErrorContext(ctx, "could not write CUE error details: "+err.Error(), "err", err)
|
||||
}
|
||||
}
|
||||
// connect errors have details and codes.
|
||||
// Refer to https://connectrpc.com/docs/go/errors
|
||||
if connectErr := new(connect.Error); errors.As(err, &connectErr) {
|
||||
for _, detail := range connectErr.Details() {
|
||||
msg, valueErr := detail.Value()
|
||||
if valueErr != nil {
|
||||
log.WarnContext(ctx, "could not decode error detail", "err", err, "type", detail.Type(), "note", "this usually means we don't have the schema for the protobuf message type")
|
||||
continue
|
||||
}
|
||||
if info, ok := msg.(*errdetails.ErrorInfo); ok {
|
||||
logDetail := log.With("reason", info.GetReason(), "domain", info.GetDomain())
|
||||
for k, v := range info.GetMetadata() {
|
||||
logDetail = logDetail.With(k, v)
|
||||
}
|
||||
logDetail.ErrorContext(ctx, info.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -30,13 +30,15 @@ func newShowPlatformCmd() (cmd *cobra.Command) {
|
||||
|
||||
var platform string
|
||||
cmd.Flags().StringVar(&platform, "platform", "./platform", "platform directory path")
|
||||
var extractYAMLs holos.StringSlice
|
||||
cmd.Flags().Var(&extractYAMLs, "extract-yaml", "data file paths to extract and unify with the platform config")
|
||||
var format string
|
||||
cmd.Flags().StringVar(&format, "format", "yaml", "yaml or json format")
|
||||
tagMap := make(holos.TagMap)
|
||||
cmd.Flags().VarP(&tagMap, "inject", "t", "set the value of a cue @tag field from a key=value pair")
|
||||
|
||||
cmd.RunE = func(c *cobra.Command, args []string) (err error) {
|
||||
inst, err := builder.LoadInstance(platform, tagMap.Tags())
|
||||
inst, err := builder.LoadInstance(platform, extractYAMLs, tagMap.Tags())
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
@@ -64,6 +66,8 @@ func newShowBuildPlanCmd() (cmd *cobra.Command) {
|
||||
|
||||
var platform string
|
||||
cmd.Flags().StringVar(&platform, "platform", "./platform", "platform directory path")
|
||||
var extractYAMLs holos.StringSlice
|
||||
cmd.Flags().Var(&extractYAMLs, "extract-yaml", "data file paths to extract and unify with the platform config")
|
||||
var format string
|
||||
cmd.Flags().StringVar(&format, "format", "yaml", "yaml or json format")
|
||||
var selector holos.Selector
|
||||
@@ -75,7 +79,7 @@ func newShowBuildPlanCmd() (cmd *cobra.Command) {
|
||||
|
||||
cmd.RunE = func(c *cobra.Command, args []string) (err error) {
|
||||
path := platform
|
||||
inst, err := builder.LoadInstance(path, tagMap.Tags())
|
||||
inst, err := builder.LoadInstance(path, extractYAMLs, tagMap.Tags())
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
@@ -122,7 +126,11 @@ func makeBuildFunc(encoder holos.OrderedEncoder, opts holos.BuildOpts) func(cont
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
tags = append(tags, opts.Tags...)
|
||||
inst, err := builder.LoadInstance(component.Path(), tags)
|
||||
filepaths, err := component.ExtractYAML()
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
inst, err := builder.LoadInstance(component.Path(), filepaths, tags)
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
|
||||
@@ -334,6 +334,11 @@ package core
|
||||
// Injected as the tag variable "holos_component_path".
|
||||
path: string @go(Path)
|
||||
|
||||
// Instances represents additional cue instance paths to unify with Path.
|
||||
// Useful to unify data files into a component BuildPlan. Added in holos
|
||||
// 0.101.7.
|
||||
instances?: [...#Instance] @go(Instances,[]Instance)
|
||||
|
||||
// WriteTo represents the holos render component --write-to flag. If empty,
|
||||
// the default value for the --write-to flag is used.
|
||||
writeTo?: string @go(WriteTo)
|
||||
@@ -353,3 +358,28 @@ package core
|
||||
// `cli.holos.run/description` to customize the log message of each BuildPlan.
|
||||
annotations?: {[string]: string} @go(Annotations,map[string]string)
|
||||
}
|
||||
|
||||
// Instance represents a data instance to unify with the configuration.
|
||||
//
|
||||
// Useful to unify json and yaml files with cue configuration files for
|
||||
// integration with other tools. For example, executing holos render platform
|
||||
// from a pull request workflow after [Kargo] executes the [yaml update] and
|
||||
// [git wait for pr] promotion steps.
|
||||
//
|
||||
// [Kargo]: https://docs.kargo.io/
|
||||
// [yaml update]: https://docs.kargo.io/references/promotion-steps#yaml-update
|
||||
// [git wait for pr]: https://docs.kargo.io/references/promotion-steps#git-wait-for-pr
|
||||
#Instance: {
|
||||
// Kind is a discriminator.
|
||||
kind: string & "ExtractYAML" @go(Kind)
|
||||
|
||||
// Ignored unless kind is ExtractYAML.
|
||||
extractYAML?: #ExtractYAML @go(ExtractYAML)
|
||||
}
|
||||
|
||||
// ExtractYAML represents a cue data instance encoded as yaml or json. If Path
|
||||
// refers to a directory all files in the directory are extracted
|
||||
// non-recursively. Otherwise, path must refer to a file.
|
||||
#ExtractYAML: {
|
||||
path: string @go(Path)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by timoni. DO NOT EDIT.
|
||||
|
||||
//timoni:generate timoni vendor crd -f projects/argocd/components/kargo/vendor/1.0.3/kargo/resources/crds/kargo.akuity.io_freights.yaml
|
||||
//timoni:generate timoni vendor crd -f /Users/jeff/Holos/kargo-demo/deploy/components/kargo/kargo.gen.yaml
|
||||
|
||||
package v1alpha1
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by timoni. DO NOT EDIT.
|
||||
|
||||
//timoni:generate timoni vendor crd -f projects/argocd/components/kargo/vendor/1.0.3/kargo/resources/crds/kargo.akuity.io_projects.yaml
|
||||
//timoni:generate timoni vendor crd -f /Users/jeff/Holos/kargo-demo/deploy/components/kargo/kargo.gen.yaml
|
||||
|
||||
package v1alpha1
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by timoni. DO NOT EDIT.
|
||||
|
||||
//timoni:generate timoni vendor crd -f projects/argocd/components/kargo/vendor/1.0.3/kargo/resources/crds/kargo.akuity.io_promotions.yaml
|
||||
//timoni:generate timoni vendor crd -f /Users/jeff/Holos/kargo-demo/deploy/components/kargo/kargo.gen.yaml
|
||||
|
||||
package v1alpha1
|
||||
|
||||
@@ -74,10 +74,90 @@ import "strings"
|
||||
// As is the alias this step can be referred to as.
|
||||
as?: string
|
||||
|
||||
// Config is the configuration for the directive.
|
||||
// Config is opaque configuration for the PromotionStep that is
|
||||
// understood
|
||||
// only by each PromotionStep's implementation. It is legal to
|
||||
// utilize
|
||||
// expressions in defining values at any level of this block.
|
||||
// See https://docs.kargo.io/references/expression-language for
|
||||
// details.
|
||||
config?: _
|
||||
|
||||
// Retry is the retry policy for this step.
|
||||
retry?: {
|
||||
// ErrorThreshold is the number of consecutive times the step must
|
||||
// fail (for
|
||||
// any reason) before retries are abandoned and the entire
|
||||
// Promotion is marked
|
||||
// as failed.
|
||||
//
|
||||
// If this field is set to 0, the effective default will be a
|
||||
// step-specific
|
||||
// one. If no step-specific default exists (i.e. is also 0), the
|
||||
// effective
|
||||
// default will be the system-wide default of 1.
|
||||
//
|
||||
// A value of 1 will cause the Promotion to be marked as failed
|
||||
// after just
|
||||
// a single failure; i.e. no retries will be attempted.
|
||||
//
|
||||
// There is no option to specify an infinite number of retries
|
||||
// using a value
|
||||
// such as -1.
|
||||
//
|
||||
// In a future release, Kargo is likely to become capable of
|
||||
// distinguishing
|
||||
// between recoverable and non-recoverable step failures. At that
|
||||
// time, it is
|
||||
// planned that unrecoverable failures will not be subject to this
|
||||
// threshold
|
||||
// and will immediately cause the Promotion to be marked as failed
|
||||
// without
|
||||
// further condition.
|
||||
errorThreshold?: int
|
||||
|
||||
// Timeout is the soft maximum interval in which a step that
|
||||
// returns a Running
|
||||
// status (which typically indicates it's waiting for something to
|
||||
// happen)
|
||||
// may be retried.
|
||||
//
|
||||
// The maximum is a soft one because the check for whether the
|
||||
// interval has
|
||||
// elapsed occurs AFTER the step has run. This effectively means a
|
||||
// step may
|
||||
// run ONCE beyond the close of the interval.
|
||||
//
|
||||
// If this field is set to nil, the effective default will be a
|
||||
// step-specific
|
||||
// one. If no step-specific default exists (i.e. is also nil), the
|
||||
// effective
|
||||
// default will be the system-wide default of 0.
|
||||
//
|
||||
// A value of 0 will cause the step to be retried indefinitely
|
||||
// unless the
|
||||
// ErrorThreshold is reached.
|
||||
timeout?: string
|
||||
}
|
||||
|
||||
// Uses identifies a runner that can execute this step.
|
||||
uses: strings.MinRunes(1)
|
||||
}]
|
||||
|
||||
// Vars is a list of variables that can be referenced by
|
||||
// expressions in
|
||||
// promotion steps.
|
||||
vars?: [...{
|
||||
// Name is the name of the variable.
|
||||
name: strings.MinRunes(1) & {
|
||||
=~"^[a-zA-Z_]\\w*$"
|
||||
}
|
||||
|
||||
// Value is the value of the variable. It is allowed to utilize
|
||||
// expressions
|
||||
// in the value.
|
||||
// See https://docs.kargo.io/references/expression-language for
|
||||
// details.
|
||||
value: string
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by timoni. DO NOT EDIT.
|
||||
|
||||
//timoni:generate timoni vendor crd -f projects/argocd/components/kargo/vendor/1.0.3/kargo/resources/crds/kargo.akuity.io_stages.yaml
|
||||
//timoni:generate timoni vendor crd -f /Users/jeff/Holos/kargo-demo/deploy/components/kargo/kargo.gen.yaml
|
||||
|
||||
package v1alpha1
|
||||
|
||||
@@ -52,6 +52,11 @@ import "strings"
|
||||
// Freight into the Stage.
|
||||
#StageSpec: {
|
||||
promotionTemplate?: {
|
||||
// PromotionTemplateSpec describes the (partial) specification of
|
||||
// a Promotion
|
||||
// for a Stage. This is a template that can be used to create a
|
||||
// Promotion for a
|
||||
// Stage.
|
||||
spec: {
|
||||
// Steps specifies the directives to be executed as part of a
|
||||
// Promotion.
|
||||
@@ -62,12 +67,92 @@ import "strings"
|
||||
// As is the alias this step can be referred to as.
|
||||
as?: string
|
||||
|
||||
// Config is the configuration for the directive.
|
||||
// Config is opaque configuration for the PromotionStep that is
|
||||
// understood
|
||||
// only by each PromotionStep's implementation. It is legal to
|
||||
// utilize
|
||||
// expressions in defining values at any level of this block.
|
||||
// See https://docs.kargo.io/references/expression-language for
|
||||
// details.
|
||||
config?: _
|
||||
|
||||
// Retry is the retry policy for this step.
|
||||
retry?: {
|
||||
// ErrorThreshold is the number of consecutive times the step must
|
||||
// fail (for
|
||||
// any reason) before retries are abandoned and the entire
|
||||
// Promotion is marked
|
||||
// as failed.
|
||||
//
|
||||
// If this field is set to 0, the effective default will be a
|
||||
// step-specific
|
||||
// one. If no step-specific default exists (i.e. is also 0), the
|
||||
// effective
|
||||
// default will be the system-wide default of 1.
|
||||
//
|
||||
// A value of 1 will cause the Promotion to be marked as failed
|
||||
// after just
|
||||
// a single failure; i.e. no retries will be attempted.
|
||||
//
|
||||
// There is no option to specify an infinite number of retries
|
||||
// using a value
|
||||
// such as -1.
|
||||
//
|
||||
// In a future release, Kargo is likely to become capable of
|
||||
// distinguishing
|
||||
// between recoverable and non-recoverable step failures. At that
|
||||
// time, it is
|
||||
// planned that unrecoverable failures will not be subject to this
|
||||
// threshold
|
||||
// and will immediately cause the Promotion to be marked as failed
|
||||
// without
|
||||
// further condition.
|
||||
errorThreshold?: int
|
||||
|
||||
// Timeout is the soft maximum interval in which a step that
|
||||
// returns a Running
|
||||
// status (which typically indicates it's waiting for something to
|
||||
// happen)
|
||||
// may be retried.
|
||||
//
|
||||
// The maximum is a soft one because the check for whether the
|
||||
// interval has
|
||||
// elapsed occurs AFTER the step has run. This effectively means a
|
||||
// step may
|
||||
// run ONCE beyond the close of the interval.
|
||||
//
|
||||
// If this field is set to nil, the effective default will be a
|
||||
// step-specific
|
||||
// one. If no step-specific default exists (i.e. is also nil), the
|
||||
// effective
|
||||
// default will be the system-wide default of 0.
|
||||
//
|
||||
// A value of 0 will cause the step to be retried indefinitely
|
||||
// unless the
|
||||
// ErrorThreshold is reached.
|
||||
timeout?: string
|
||||
}
|
||||
|
||||
// Uses identifies a runner that can execute this step.
|
||||
uses: strings.MinRunes(1)
|
||||
}] & [_, ...]
|
||||
|
||||
// Vars is a list of variables that can be referenced by
|
||||
// expressions in
|
||||
// promotion steps.
|
||||
vars?: [...{
|
||||
// Name is the name of the variable.
|
||||
name: strings.MinRunes(1) & {
|
||||
=~"^[a-zA-Z_]\\w*$"
|
||||
}
|
||||
|
||||
// Value is the value of the variable. It is allowed to utilize
|
||||
// expressions
|
||||
// in the value.
|
||||
// See https://docs.kargo.io/references/expression-language for
|
||||
// details.
|
||||
value: string
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by timoni. DO NOT EDIT.
|
||||
|
||||
//timoni:generate timoni vendor crd -f projects/argocd/components/kargo/vendor/1.0.3/kargo/resources/crds/kargo.akuity.io_warehouses.yaml
|
||||
//timoni:generate timoni vendor crd -f /Users/jeff/Holos/kargo-demo/deploy/components/kargo/kargo.gen.yaml
|
||||
|
||||
package v1alpha1
|
||||
|
||||
@@ -52,6 +52,7 @@ import "strings"
|
||||
// This field is optional. When left unspecified, the field is
|
||||
// implicitly
|
||||
// treated as if its value were "Automatic".
|
||||
// Accepted values: Automatic, Manual
|
||||
freightCreationPolicy?: "Automatic" | "Manual" | *"Automatic"
|
||||
|
||||
// Interval is the reconciliation interval for this Warehouse. On
|
||||
@@ -170,6 +171,7 @@ import "strings"
|
||||
// field is optional. When left unspecified, the field is
|
||||
// implicitly treated
|
||||
// as if its value were "NewestFromBranch".
|
||||
// Accepted values: Lexical, NewestFromBranch, NewestTag, SemVer
|
||||
commitSelectionStrategy?: "Lexical" | "NewestFromBranch" | "NewestTag" | "SemVer" | *"NewestFromBranch"
|
||||
|
||||
// DiscoveryLimit is an optional limit on the number of commits
|
||||
@@ -324,6 +326,7 @@ import "strings"
|
||||
// left unspecified, the field is implicitly treated as if its
|
||||
// value were
|
||||
// "SemVer".
|
||||
// Accepted values: Digest, Lexical, NewestBuild, SemVer
|
||||
imageSelectionStrategy?: "Digest" | "Lexical" | "NewestBuild" | "SemVer" | *"SemVer"
|
||||
|
||||
// InsecureSkipTLSVerify specifies whether certificate
|
||||
|
||||
@@ -21,6 +21,8 @@ type Platform interface {
|
||||
type Component interface {
|
||||
Describe() string
|
||||
Path() string
|
||||
// ExtractYAML represents the values of the --extract-yaml flag
|
||||
ExtractYAML() ([]string, error)
|
||||
Tags() ([]string, error)
|
||||
WriteTo() string
|
||||
Labels() Labels
|
||||
|
||||
@@ -42,6 +42,9 @@ func FindCueMod(path string) (root string, err error) {
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// FindRootLeaf returns the root path containing the cue.mod and the leaf path
|
||||
// relative to the root for the given target path. FindRootLeaf calls
|
||||
// [filepath.Clean] on the returned paths.
|
||||
func FindRootLeaf(target string) (root string, leaf string, err error) {
|
||||
if root, err = FindCueMod(target); err != nil {
|
||||
return "", "", err
|
||||
|
||||
@@ -4,5 +4,5 @@ deps:
|
||||
- remote: buf.build
|
||||
owner: bufbuild
|
||||
repository: protovalidate
|
||||
commit: 5a7b106cbb87462d9a8c9ffecdbd2e38
|
||||
digest: shake256:2f7efa5a904668219f039d4f6eeb51e871f8f7f5966055a10663cba335bd65f76cac84da3fa758ab7b5dcb489ec599521390ce3951d119fb56df1fc2def16bb0
|
||||
commit: a3320276596649bcad929ac829d451f4
|
||||
digest: shake256:a6e5f64fd3fd47e3e8568e9753f59a1566f56c11ec055baf65463d3bca3499f6f16c2d6f5628fa41cfd0f4fa7e72abe65be4efd77d269749492472ed4cc4070d
|
||||
|
||||
@@ -1 +1 @@
|
||||
6
|
||||
8
|
||||
|
||||
Reference in New Issue
Block a user