mirror of
https://github.com/holos-run/holos.git
synced 2026-03-16 01:39:01 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3343d226e5 | ||
|
|
f3a9b7cfbc | ||
|
|
53b7246d5e | ||
|
|
c20872c92f | ||
|
|
ecce1f797e | ||
|
|
0d7033d063 | ||
|
|
84bf0c8945 |
@@ -1,28 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/holos-run/holos/pkg/cli"
|
||||
"github.com/holos-run/holos/pkg/config"
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.New()
|
||||
slog.SetDefault(cfg.Logger())
|
||||
ctx := context.Background()
|
||||
if err := cli.New(cfg).ExecuteContext(ctx); err != nil {
|
||||
log := cfg.NewTopLevelLogger()
|
||||
var errAt *wrapper.ErrorAt
|
||||
const msg = "could not execute"
|
||||
if ok := errors.As(err, &errAt); ok {
|
||||
log.ErrorContext(ctx, msg, "err", errAt.Unwrap(), "loc", errAt.Source.Loc())
|
||||
} else {
|
||||
log.ErrorContext(ctx, msg, "err", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(cli.MakeMain()())
|
||||
}
|
||||
|
||||
20
cmd/holos/main_test.go
Normal file
20
cmd/holos/main_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/holos-run/holos/pkg/cli"
|
||||
"github.com/rogpeppe/go-internal/testscript"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(testscript.RunMain(m, map[string]func() int{
|
||||
"holos": cli.MakeMain(),
|
||||
}))
|
||||
}
|
||||
|
||||
func TestGetSecrets(t *testing.T) {
|
||||
testscript.Run(t, testscript.Params{
|
||||
Dir: "testdata",
|
||||
})
|
||||
}
|
||||
5
cmd/holos/testdata/version.txt
vendored
Normal file
5
cmd/holos/testdata/version.txt
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
exec holos --version
|
||||
# want version with no v on stdout
|
||||
stdout -count=1 '^\d+\.\d+\.\d+$'
|
||||
# want nothing on stderr
|
||||
! stderr .
|
||||
@@ -1,3 +0,0 @@
|
||||
package holos
|
||||
|
||||
#InputKeys: component: "eso"
|
||||
@@ -2,6 +2,8 @@ package holos
|
||||
|
||||
#TargetNamespace: "external-secrets"
|
||||
|
||||
#InputKeys: component: "eso"
|
||||
|
||||
#InputKeys: {
|
||||
project: "secrets"
|
||||
service: "eso"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package holos
|
||||
|
||||
#Kustomization: spec: dependsOn: [{name: #InstancePrefix + "-namespaces"}]
|
||||
#Kustomization: spec: {
|
||||
dependsOn: [{name: #InstancePrefix + "-namespaces"}]
|
||||
targetNamespace: #TargetNamespace
|
||||
}
|
||||
|
||||
#HelmChart & {
|
||||
values: installCrds: true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package holos
|
||||
|
||||
#PlatformNamespaces: [
|
||||
{name: "external-secrets"},
|
||||
{name: "holos-system"},
|
||||
{name: "flux-system"},
|
||||
{name: "ceph-system"},
|
||||
|
||||
@@ -79,6 +79,8 @@ _apiVersion: "holos.run/v1alpha1"
|
||||
kind: string | *"GitRepository"
|
||||
name: string | *"flux-system"
|
||||
}
|
||||
suspend?: bool
|
||||
targetNamespace?: string
|
||||
timeout: string | *"3m0s"
|
||||
wait: bool | *true
|
||||
}
|
||||
|
||||
4
go.mod
4
go.mod
@@ -17,6 +17,7 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||
github.com/emicklei/proto v1.10.0 // indirect
|
||||
github.com/evanphx/json-patch v4.12.0+incompatible // indirect
|
||||
github.com/go-logr/logr v1.3.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
@@ -38,7 +39,9 @@ require (
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0-rc4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/oauth2 v0.10.0 // indirect
|
||||
@@ -54,6 +57,7 @@ require (
|
||||
k8s.io/api v0.29.2 // indirect
|
||||
k8s.io/klog/v2 v2.110.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect
|
||||
k8s.io/kubectl v0.29.2 // indirect
|
||||
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -13,6 +13,8 @@ github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxER
|
||||
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/emicklei/proto v1.10.0 h1:pDGyFRVV5RvV+nkBK9iy3q67FBy9Xa7vwrOTE+g5aGw=
|
||||
github.com/emicklei/proto v1.10.0/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
|
||||
github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=
|
||||
github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
|
||||
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
|
||||
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
|
||||
@@ -88,12 +90,16 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0-rc4 h1:oOxKUJWnFC4YGHCCMNql1x4YaDfYBTS5Y4x/Cgeo1E0=
|
||||
github.com/opencontainers/image-spec v1.1.0-rc4/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 h1:sadMIsgmHpEOGbUs6VtHBXRR1OHevnj7hLx9ZcdNGW4=
|
||||
github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c=
|
||||
github.com/rogpeppe/go-internal v1.11.1-0.20231026093722-fa6a31e0812c h1:fPpdjePK1atuOg28PXfNSqgwf9I/qD1Hlo39JFwKBXk=
|
||||
github.com/rogpeppe/go-internal v1.11.1-0.20231026093722-fa6a31e0812c/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
@@ -183,6 +189,8 @@ k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
|
||||
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
|
||||
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780=
|
||||
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA=
|
||||
k8s.io/kubectl v0.29.2 h1:uaDYaBhumvkwz0S2XHt36fK0v5IdNgL7HyUniwb2IUo=
|
||||
k8s.io/kubectl v0.29.2/go.mod h1:BhizuYBGcKaHWyq+G7txGw2fXg576QbPrrnQdQDZgqI=
|
||||
k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI=
|
||||
k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
|
||||
|
||||
@@ -3,7 +3,7 @@ package build
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/holos-run/holos/pkg/cli/command"
|
||||
"github.com/holos-run/holos/pkg/config"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/holos-run/holos/pkg/internal/builder"
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// makeBuildRunFunc returns the internal implementation of the build cli command
|
||||
func makeBuildRunFunc(cfg *config.Config) command.RunFunc {
|
||||
func makeBuildRunFunc(cfg *holos.Config) command.RunFunc {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
build := builder.New(builder.Entrypoints(args), builder.Cluster(cfg.ClusterName()))
|
||||
results, err := build.Run(cmd.Context())
|
||||
@@ -31,7 +31,7 @@ func makeBuildRunFunc(cfg *config.Config) command.RunFunc {
|
||||
}
|
||||
|
||||
// New returns the build subcommand for the root command
|
||||
func New(cfg *config.Config) *cobra.Command {
|
||||
func New(cfg *holos.Config) *cobra.Command {
|
||||
cmd := command.New("build [directory...]")
|
||||
cmd.Args = cobra.MinimumNArgs(1)
|
||||
cmd.Short = "build kubernetes api objects from a directory"
|
||||
|
||||
23
pkg/cli/create/create.go
Normal file
23
pkg/cli/create/create.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package create
|
||||
|
||||
import (
|
||||
"github.com/holos-run/holos/pkg/cli/command"
|
||||
"github.com/holos-run/holos/pkg/cli/secret"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// New returns the create command for the cli
|
||||
func New(hc *holos.Config) *cobra.Command {
|
||||
cmd := command.New("create")
|
||||
cmd.Short = "create resources"
|
||||
cmd.Flags().SortFlags = false
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
return c.Usage()
|
||||
}
|
||||
// flags
|
||||
cmd.PersistentFlags().SortFlags = false
|
||||
// commands
|
||||
cmd.AddCommand(secret.NewCreateCmd(hc))
|
||||
return cmd
|
||||
}
|
||||
23
pkg/cli/get/get.go
Normal file
23
pkg/cli/get/get.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package get
|
||||
|
||||
import (
|
||||
"github.com/holos-run/holos/pkg/cli/command"
|
||||
"github.com/holos-run/holos/pkg/cli/secret"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// New returns the get command for the cli.
|
||||
func New(hc *holos.Config) *cobra.Command {
|
||||
cmd := command.New("get")
|
||||
cmd.Short = "get resources"
|
||||
cmd.Flags().SortFlags = false
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
return c.Usage()
|
||||
}
|
||||
// flags
|
||||
cmd.PersistentFlags().SortFlags = false
|
||||
// commands
|
||||
cmd.AddCommand(secret.NewGetCmd(hc))
|
||||
return cmd
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package kv
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/holos-run/holos/pkg/cli/command"
|
||||
"github.com/holos-run/holos/pkg/config"
|
||||
"github.com/holos-run/holos/pkg/cli/secret"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/holos-run/holos/pkg/logger"
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -10,17 +13,28 @@ import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
func newGetCmd(cfg *config.Config) *cobra.Command {
|
||||
type getConfig struct {
|
||||
file *string
|
||||
}
|
||||
|
||||
func newGetCmd(cfg *holos.Config) *cobra.Command {
|
||||
cmd := command.New("get")
|
||||
cmd.Args = cobra.MinimumNArgs(1)
|
||||
cmd.Short = "print secret data in txtar format"
|
||||
|
||||
cf := getConfig{}
|
||||
flagSet := flag.NewFlagSet("", flag.ContinueOnError)
|
||||
cf.file = flagSet.String("file", "", "file to print to stdout")
|
||||
|
||||
cmd.Flags().SortFlags = false
|
||||
cmd.RunE = makeGetRunFunc(cfg)
|
||||
cmd.Flags().AddGoFlagSet(cfg.ClusterFlagSet())
|
||||
cmd.Flags().AddGoFlagSet(flagSet)
|
||||
cmd.RunE = makeGetRunFunc(cfg, cf)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func makeGetRunFunc(cfg *config.Config) command.RunFunc {
|
||||
func makeGetRunFunc(cfg *holos.Config, cf getConfig) command.RunFunc {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
log := logger.FromContext(ctx)
|
||||
@@ -31,9 +45,12 @@ func makeGetRunFunc(cfg *config.Config) command.RunFunc {
|
||||
}
|
||||
|
||||
for _, name := range args {
|
||||
nlog := log.With(NameLabel, name)
|
||||
nlog := log.With(secret.NameLabel, name)
|
||||
opts := metav1.ListOptions{
|
||||
LabelSelector: NameLabel + "=" + name,
|
||||
LabelSelector: secret.NameLabel + "=" + name,
|
||||
}
|
||||
if name := cfg.ClusterName(); name != "" {
|
||||
opts.LabelSelector += fmt.Sprintf(",%s=%s", secret.ClusterLabel, name)
|
||||
}
|
||||
list, err := cs.CoreV1().Secrets(cfg.KVNamespace()).List(ctx, opts)
|
||||
if err != nil {
|
||||
@@ -51,13 +68,25 @@ func makeGetRunFunc(cfg *config.Config) command.RunFunc {
|
||||
// most recent secret is the one we want.
|
||||
secret := list.Items[len(list.Items)-1]
|
||||
|
||||
keys := make([]string, 0, len(secret.Data))
|
||||
for k, v := range secret.Data {
|
||||
keys = append(keys, k)
|
||||
nlog.DebugContext(ctx, "data", "name", secret.Name, "key", k, "len", len(v))
|
||||
}
|
||||
|
||||
// Print one file to stdout
|
||||
if key := *cf.file; key != "" {
|
||||
if data, found := secret.Data[key]; found {
|
||||
cfg.Write(command.EnsureNewline(data))
|
||||
return nil
|
||||
}
|
||||
return wrapper.Wrap(fmt.Errorf("not found: %s have %#v", key, keys))
|
||||
}
|
||||
|
||||
if len(secret.Data) > 0 {
|
||||
cfg.Println(secret.Name)
|
||||
}
|
||||
|
||||
for k, v := range secret.Data {
|
||||
cfg.Printf("-- %s --\n", k)
|
||||
cfg.Write(command.EnsureNewline(v))
|
||||
|
||||
@@ -2,17 +2,15 @@ package kv
|
||||
|
||||
import (
|
||||
"github.com/holos-run/holos/pkg/cli/command"
|
||||
"github.com/holos-run/holos/pkg/config"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
const NameLabel = "holos.run/secret.name"
|
||||
|
||||
// New returns the kv root command for the cli
|
||||
func New(cfg *config.Config) *cobra.Command {
|
||||
func New(cfg *holos.Config) *cobra.Command {
|
||||
cmd := command.New("kv")
|
||||
cmd.Short = "work with secrets in the provisioner cluster"
|
||||
cmd.Flags().SortFlags = false
|
||||
@@ -25,10 +23,11 @@ func New(cfg *config.Config) *cobra.Command {
|
||||
// subcommands
|
||||
cmd.AddCommand(newGetCmd(cfg))
|
||||
cmd.AddCommand(newListCmd(cfg))
|
||||
cmd.AddCommand(newPutCmd(cfg))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newClientSet(cfg *config.Config) (*kubernetes.Clientset, error) {
|
||||
func newClientSet(cfg *holos.Config) (*kubernetes.Clientset, error) {
|
||||
kcfg, err := clientcmd.BuildConfigFromFlags("", cfg.KVKubeconfig())
|
||||
if err != nil {
|
||||
return nil, wrapper.Wrap(err)
|
||||
|
||||
@@ -2,37 +2,39 @@ package kv
|
||||
|
||||
import (
|
||||
"github.com/holos-run/holos/pkg/cli/command"
|
||||
"github.com/holos-run/holos/pkg/config"
|
||||
"github.com/holos-run/holos/pkg/cli/secret"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"github.com/spf13/cobra"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func newListCmd(cfg *config.Config) *cobra.Command {
|
||||
func newListCmd(cfg *holos.Config) *cobra.Command {
|
||||
cmd := command.New("list")
|
||||
cmd.Args = cobra.NoArgs
|
||||
cmd.Short = "list secrets"
|
||||
cmd.Flags().SortFlags = false
|
||||
cmd.Flags().AddGoFlagSet(cfg.ClusterFlagSet())
|
||||
cmd.RunE = makeListRunFunc(cfg)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func makeListRunFunc(cfg *config.Config) command.RunFunc {
|
||||
func makeListRunFunc(cfg *holos.Config) command.RunFunc {
|
||||
return func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
cs, err := newClientSet(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
selector := metav1.ListOptions{LabelSelector: NameLabel}
|
||||
selector := metav1.ListOptions{LabelSelector: secret.NameLabel}
|
||||
secrets, err := cs.CoreV1().Secrets(cfg.KVNamespace()).List(ctx, selector)
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
labels := make(map[string]bool)
|
||||
for _, secret := range secrets.Items {
|
||||
if value, ok := secret.Labels[NameLabel]; ok {
|
||||
for _, s := range secrets.Items {
|
||||
if value, ok := s.Labels[secret.NameLabel]; ok {
|
||||
labels[value] = true
|
||||
}
|
||||
}
|
||||
|
||||
200
pkg/cli/kv/put.go
Normal file
200
pkg/cli/kv/put.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package kv
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/holos-run/holos/pkg/cli/command"
|
||||
"github.com/holos-run/holos/pkg/cli/secret"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/holos-run/holos/pkg/logger"
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/tools/txtar"
|
||||
"io"
|
||||
"io/fs"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/kubectl/pkg/util/hash"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sigs.k8s.io/yaml"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type putConfig struct {
|
||||
secretName *string
|
||||
file *string
|
||||
dryRun *bool
|
||||
}
|
||||
|
||||
func newPutCmd(cfg *holos.Config) *cobra.Command {
|
||||
cmd := command.New("put")
|
||||
cmd.Args = cobra.MinimumNArgs(0)
|
||||
cmd.Short = "put a secret from stdin or file args"
|
||||
cmd.Flags().SortFlags = false
|
||||
|
||||
pcfg := putConfig{}
|
||||
flagSet := flag.NewFlagSet("", flag.ContinueOnError)
|
||||
pcfg.secretName = flagSet.String("name", "", "secret name to use instead of txtar comment")
|
||||
pcfg.file = flagSet.String("file", "", "file name to use instead of txtar path")
|
||||
pcfg.dryRun = flagSet.Bool("dry-run", false, "print to standard output instead of creating")
|
||||
|
||||
cmd.Flags().AddGoFlagSet(flagSet)
|
||||
cmd.Flags().AddGoFlagSet(cfg.ClusterFlagSet())
|
||||
cmd.RunE = makePutRunFunc(cfg, pcfg)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func makePutRunFunc(cfg *holos.Config, pcfg putConfig) command.RunFunc {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
a := &txtar.Archive{}
|
||||
|
||||
// Add stdin to the archive.
|
||||
if len(args) == 0 {
|
||||
data, err := io.ReadAll(cfg.Stdin())
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
|
||||
if *pcfg.file != "" {
|
||||
file := txtar.File{
|
||||
Name: *pcfg.file,
|
||||
Data: data,
|
||||
}
|
||||
a.Files = append(a.Files, file)
|
||||
} else {
|
||||
a = txtar.Parse(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Do we have a secret name?
|
||||
if *pcfg.secretName != "" {
|
||||
a.Comment = []byte(*pcfg.secretName)
|
||||
}
|
||||
if len(a.Comment) == 0 {
|
||||
// Use the first argument if not
|
||||
if len(args) > 0 {
|
||||
a.Comment = []byte(filepath.Base(args[0]))
|
||||
} else {
|
||||
err := fmt.Errorf("missing secret name from name, args, or txtar comment")
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
head, _, _ := bytes.Cut(a.Comment, []byte("\n"))
|
||||
secretName := string(head)
|
||||
|
||||
// Add files from the filesystem to the archive
|
||||
for _, name := range args {
|
||||
if err := filepath.WalkDir(name, makeWalkFunc(a, name)); err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
log := logger.FromContext(cmd.Context())
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Nothing to do?
|
||||
if len(a.Files) == 0 {
|
||||
log.WarnContext(ctx, "nothing to do")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create the secret.
|
||||
secret, err := createSecret(ctx, cfg, pcfg, a, secretName)
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
|
||||
if *pcfg.dryRun {
|
||||
data, err := yaml.Marshal(secret)
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
cfg.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Make the API call
|
||||
cs, err := newClientSet(cfg)
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
|
||||
secret, err = cs.CoreV1().Secrets(cfg.KVNamespace()).Create(ctx, secret, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
|
||||
log.InfoContext(ctx, "created: "+secret.Name, "secret", secret.Name, "name", secretName, "namespace", secret.Namespace)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func createSecret(ctx context.Context, cfg *holos.Config, pcfg putConfig, a *txtar.Archive, secretName string) (*v1.Secret, error) {
|
||||
secretData := make(map[string][]byte)
|
||||
for _, file := range a.Files {
|
||||
secretData[file.Name] = file.Data
|
||||
}
|
||||
|
||||
labels := map[string]string{secret.NameLabel: secretName}
|
||||
if owner := os.Getenv("USER"); owner != "" {
|
||||
labels[secret.OwnerLabel] = owner
|
||||
}
|
||||
if cluster := cfg.ClusterName(); cluster != "" {
|
||||
labels[secret.ClusterLabel] = cluster
|
||||
}
|
||||
|
||||
secret := &v1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: secretName,
|
||||
Labels: labels,
|
||||
},
|
||||
Data: secretData,
|
||||
}
|
||||
|
||||
secretHash, err := hash.SecretHash(secret)
|
||||
if err != nil {
|
||||
return nil, wrapper.Wrap(err)
|
||||
}
|
||||
secret.Name = fmt.Sprintf("%s-%s", secret.Name, secretHash)
|
||||
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func makeWalkFunc(a *txtar.Archive, rootDir string) fs.WalkDirFunc {
|
||||
return func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Depth is the count of path separators from the root
|
||||
depth := strings.Count(path[len(rootDir):], string(filepath.Separator))
|
||||
|
||||
if depth > 1 {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
|
||||
if !d.IsDir() {
|
||||
if file, err := file(path); err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
} else {
|
||||
file.Name = filepath.Base(path)
|
||||
a.Files = append(a.Files, file)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func file(path string) (file txtar.File, err error) {
|
||||
file.Name = path
|
||||
file.Data, err = os.ReadFile(path)
|
||||
return
|
||||
}
|
||||
35
pkg/cli/main.go
Normal file
35
pkg/cli/main.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// 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 err := New(cfg).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) {
|
||||
log := hc.NewTopLevelLogger()
|
||||
var errAt *wrapper.ErrorAt
|
||||
const msg = "could not execute"
|
||||
if ok := errors.As(err, &errAt); ok {
|
||||
log.ErrorContext(ctx, msg, "err", errAt.Unwrap(), "loc", errAt.Source.Loc())
|
||||
} else {
|
||||
log.ErrorContext(ctx, msg, "err", err)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -3,14 +3,14 @@ package render
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/holos-run/holos/pkg/cli/command"
|
||||
"github.com/holos-run/holos/pkg/config"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/holos-run/holos/pkg/internal/builder"
|
||||
"github.com/holos-run/holos/pkg/logger"
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func makeRenderRunFunc(cfg *config.Config) command.RunFunc {
|
||||
func makeRenderRunFunc(cfg *holos.Config) command.RunFunc {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if cfg.ClusterName() == "" {
|
||||
return wrapper.Wrap(fmt.Errorf("missing cluster name"))
|
||||
@@ -44,7 +44,7 @@ func makeRenderRunFunc(cfg *config.Config) command.RunFunc {
|
||||
}
|
||||
|
||||
// New returns the render subcommand for the root command
|
||||
func New(cfg *config.Config) *cobra.Command {
|
||||
func New(cfg *holos.Config) *cobra.Command {
|
||||
cmd := command.New("render [directory...]")
|
||||
cmd.Args = cobra.MinimumNArgs(1)
|
||||
cmd.Short = "write kubernetes api objects to the filesystem"
|
||||
|
||||
@@ -2,10 +2,12 @@ package cli
|
||||
|
||||
import (
|
||||
"github.com/holos-run/holos/pkg/cli/build"
|
||||
"github.com/holos-run/holos/pkg/cli/create"
|
||||
"github.com/holos-run/holos/pkg/cli/get"
|
||||
"github.com/holos-run/holos/pkg/cli/kv"
|
||||
"github.com/holos-run/holos/pkg/cli/render"
|
||||
"github.com/holos-run/holos/pkg/cli/txtar"
|
||||
"github.com/holos-run/holos/pkg/config"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/holos-run/holos/pkg/logger"
|
||||
"github.com/holos-run/holos/pkg/version"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -13,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
// New returns a new root *cobra.Command for command line execution.
|
||||
func New(cfg *config.Config) *cobra.Command {
|
||||
func New(cfg *holos.Config) *cobra.Command {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "holos",
|
||||
Short: "holos manages a holistic integrated software development platform",
|
||||
@@ -47,8 +49,14 @@ func New(cfg *config.Config) *cobra.Command {
|
||||
// subcommands
|
||||
rootCmd.AddCommand(build.New(cfg))
|
||||
rootCmd.AddCommand(render.New(cfg))
|
||||
rootCmd.AddCommand(kv.New(cfg))
|
||||
rootCmd.AddCommand(get.New(cfg))
|
||||
rootCmd.AddCommand(create.New(cfg))
|
||||
|
||||
// Maybe not needed?
|
||||
rootCmd.AddCommand(txtar.New(cfg))
|
||||
|
||||
// Deprecated, remove?
|
||||
rootCmd.AddCommand(kv.New(cfg))
|
||||
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/holos-run/holos/pkg/config"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/holos-run/holos/pkg/logger"
|
||||
"github.com/holos-run/holos/pkg/version"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func newCommand() (*cobra.Command, *bytes.Buffer) {
|
||||
var b1, b2 bytes.Buffer
|
||||
// discard stdout for now, it's a bunch of usage messages.
|
||||
cmd := New(config.New(config.Stdout(&b1), config.Stderr(&b2)))
|
||||
cmd := New(holos.New(holos.Stdout(&b1), holos.Stderr(&b2)))
|
||||
return cmd, &b2
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestInvalidArgs(t *testing.T) {
|
||||
}
|
||||
for _, args := range invalidArgs {
|
||||
var b bytes.Buffer
|
||||
cmd := New(config.New(config.Stdout(&b)))
|
||||
cmd := New(holos.New(holos.Stdout(&b)))
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -114,7 +114,7 @@ func TestLoggerFromContext(t *testing.T) {
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
cmd := New(config.New(config.Stdout(&b)))
|
||||
cmd := New(holos.New(holos.Stdout(&b)))
|
||||
cmd.SetOut(&b)
|
||||
cmd.SetArgs([]string{"--version"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
|
||||
128
pkg/cli/secret/create.go
Normal file
128
pkg/cli/secret/create.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/holos-run/holos/pkg/cli/command"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/holos-run/holos/pkg/logger"
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"github.com/spf13/cobra"
|
||||
"io/fs"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/kubectl/pkg/util/hash"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sigs.k8s.io/yaml"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NewCreateCmd(hc *holos.Config) *cobra.Command {
|
||||
cmd := command.New("secret NAME [--from-file=source]")
|
||||
cmd.Aliases = []string{"secrets", "sec"}
|
||||
cmd.Args = cobra.ExactArgs(1)
|
||||
cmd.Short = "Create a holos secret from files or directories"
|
||||
|
||||
cfg, flagSet := newConfig()
|
||||
flagSet.Var(&cfg.files, "from-file", "store files as keys in the secret")
|
||||
cfg.dryRun = flagSet.Bool("dry-run", false, "dry run")
|
||||
|
||||
cmd.Flags().SortFlags = false
|
||||
cmd.Flags().AddGoFlagSet(flagSet)
|
||||
cmd.RunE = makeCreateRunFunc(hc, cfg)
|
||||
return cmd
|
||||
|
||||
}
|
||||
|
||||
func makeCreateRunFunc(hc *holos.Config, cfg *config) command.RunFunc {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
log := logger.FromContext(ctx)
|
||||
secretName := args[0]
|
||||
secret := &v1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Secret",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: secretName,
|
||||
Labels: map[string]string{NameLabel: secretName},
|
||||
},
|
||||
Data: make(secretData),
|
||||
}
|
||||
|
||||
if *cfg.cluster != "" {
|
||||
clusterPrefix := fmt.Sprintf("%s-", *cfg.cluster)
|
||||
if !strings.HasPrefix(secretName, clusterPrefix) {
|
||||
const msg = "missing cluster name prefix"
|
||||
log.WarnContext(ctx, msg, "have", secretName, "want", clusterPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range cfg.files {
|
||||
if err := filepath.WalkDir(file, makeWalkFunc(secret.Data, file)); err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
if owner := os.Getenv("USER"); owner != "" {
|
||||
secret.Labels[OwnerLabel] = owner
|
||||
}
|
||||
if *cfg.cluster != "" {
|
||||
secret.Labels[ClusterLabel] = *cfg.cluster
|
||||
}
|
||||
|
||||
if secretHash, err := hash.SecretHash(secret); err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
} else {
|
||||
secret.Name = fmt.Sprintf("%s-%s", secret.Name, secretHash)
|
||||
}
|
||||
|
||||
if *cfg.dryRun {
|
||||
out, err := yaml.Marshal(secret)
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
hc.Write(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
cs, err := hc.ProvisionerClientset()
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
secret, err = cs.CoreV1().
|
||||
Secrets(*cfg.namespace).
|
||||
Create(ctx, secret, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
|
||||
log.InfoContext(ctx, "created: "+secret.Name, "secret", secret.Name, "name", secretName, "namespace", secret.Namespace)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func makeWalkFunc(data secretData, root string) fs.WalkDirFunc {
|
||||
return func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Depth is the count of path separators from the root
|
||||
depth := strings.Count(path[len(root):], string(filepath.Separator))
|
||||
|
||||
if depth > 1 {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
if !d.IsDir() {
|
||||
key := filepath.Base(path)
|
||||
if data[key], err = os.ReadFile(path); err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
147
pkg/cli/secret/get.go
Normal file
147
pkg/cli/secret/get.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/holos-run/holos/pkg/cli/command"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/holos-run/holos/pkg/logger"
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"github.com/spf13/cobra"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const printFlagName = "print-key"
|
||||
|
||||
func NewGetCmd(hc *holos.Config) *cobra.Command {
|
||||
cmd := command.New("secrets NAME [--to-file=destination]")
|
||||
cmd.Aliases = []string{"secret"}
|
||||
cmd.Args = cobra.MinimumNArgs(0)
|
||||
cmd.Short = "Get holos secrets from the provisioner cluster"
|
||||
|
||||
cfg, flagSet := newConfig()
|
||||
flagSet.Var(&cfg.files, "to-file", "extract files from the secret")
|
||||
cfg.printFile = flagSet.String(printFlagName, "", "print one key from the secret")
|
||||
cfg.extract = flagSet.Bool("extract-all", false, "extract all files from the secret")
|
||||
cfg.extractTo = flagSet.String("extract-to", ".", "extract to directory")
|
||||
|
||||
cmd.Flags().SortFlags = false
|
||||
cmd.Flags().AddGoFlagSet(flagSet)
|
||||
cmd.RunE = makeGetRunFunc(hc, cfg)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func makeGetRunFunc(hc *holos.Config, cfg *config) command.RunFunc {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
namespace := *cfg.namespace
|
||||
ctx := cmd.Context()
|
||||
log := logger.FromContext(ctx).With("namespace", namespace)
|
||||
|
||||
cs, err := hc.ProvisionerClientset()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// List secrets if no arguments.
|
||||
if len(args) == 0 {
|
||||
return listSecrets(cmd.Context(), hc, namespace)
|
||||
}
|
||||
|
||||
// Get each secret.
|
||||
for _, secretName := range args {
|
||||
log := log.With(NameLabel, secretName)
|
||||
opts := metav1.ListOptions{
|
||||
LabelSelector: fmt.Sprintf("%s=%s", NameLabel, secretName),
|
||||
}
|
||||
list, err := cs.CoreV1().Secrets(namespace).List(ctx, opts)
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
|
||||
log.DebugContext(ctx, "results", "len", len(list.Items))
|
||||
if len(list.Items) < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort oldest first.
|
||||
sort.Slice(list.Items, func(i, j int) bool {
|
||||
return list.Items[i].CreationTimestamp.Before(&list.Items[j].CreationTimestamp)
|
||||
})
|
||||
|
||||
// Get the most recent.
|
||||
secret := list.Items[len(list.Items)-1]
|
||||
log = log.With("secret", secret.Name)
|
||||
|
||||
// Extract the data keys (file names).
|
||||
keys := make([]string, 0, len(secret.Data))
|
||||
for k, v := range secret.Data {
|
||||
keys = append(keys, k)
|
||||
log.DebugContext(ctx, "data", "name", secret.Name, "key", k, "len", len(v))
|
||||
}
|
||||
|
||||
// Extract specified files or all files.
|
||||
toExtract := cfg.files
|
||||
if *cfg.extract {
|
||||
toExtract = keys
|
||||
}
|
||||
|
||||
printFile := *cfg.printFile
|
||||
if len(toExtract) == 0 {
|
||||
if printFile == "" {
|
||||
printFile = secretName
|
||||
}
|
||||
}
|
||||
|
||||
if printFile != "" {
|
||||
if data, found := secret.Data[printFile]; found {
|
||||
hc.Write(data)
|
||||
} else {
|
||||
err := fmt.Errorf("cannot print: want %s have %v: did you mean --extract-all or --%s=name", printFile, keys, printFlagName)
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over --to-file values.
|
||||
for _, name := range toExtract {
|
||||
data, found := secret.Data[name]
|
||||
if !found {
|
||||
err := fmt.Errorf("%s not found in %v", name, keys)
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
path := filepath.Join(*cfg.extractTo, name)
|
||||
if err := os.WriteFile(path, data, 0666); err != nil {
|
||||
return wrapper.Wrap(fmt.Errorf("could not write %s: %w", path, err))
|
||||
}
|
||||
log.InfoContext(ctx, "wrote: "+path, "name", name, "bytes", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// listSecrets lists holos secrets in the provisioner cluster
|
||||
func listSecrets(ctx context.Context, hc *holos.Config, namespace string) error {
|
||||
cs, err := hc.ProvisionerClientset()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
selector := metav1.ListOptions{LabelSelector: NameLabel}
|
||||
secrets, err := cs.CoreV1().Secrets(namespace).List(ctx, selector)
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
secretNames := make(map[string]bool)
|
||||
for _, secret := range secrets.Items {
|
||||
if labelValue, ok := secret.Labels[NameLabel]; ok {
|
||||
secretNames[labelValue] = true
|
||||
}
|
||||
}
|
||||
for secretName := range secretNames {
|
||||
hc.Println(secretName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
30
pkg/cli/secret/secret.go
Normal file
30
pkg/cli/secret/secret.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
)
|
||||
|
||||
const NameLabel = "holos.run/secret.name"
|
||||
const OwnerLabel = "holos.run/owner.name"
|
||||
const ClusterLabel = "holos.run/cluster.name"
|
||||
|
||||
type secretData map[string][]byte
|
||||
|
||||
type config struct {
|
||||
files holos.StringSlice
|
||||
printFile *string
|
||||
extract *bool
|
||||
dryRun *bool
|
||||
cluster *string
|
||||
namespace *string
|
||||
extractTo *string
|
||||
}
|
||||
|
||||
func newConfig() (*config, *flag.FlagSet) {
|
||||
cfg := &config{}
|
||||
flagSet := flag.NewFlagSet("", flag.ContinueOnError)
|
||||
cfg.namespace = flagSet.String("namespace", holos.DefaultProvisionerNamespace, "namespace in the provisioner cluster")
|
||||
cfg.cluster = flagSet.String("cluster-name", "", "cluster name selector")
|
||||
return cfg, flagSet
|
||||
}
|
||||
81
pkg/cli/secret/secret_test.go
Normal file
81
pkg/cli/secret/secret_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"github.com/holos-run/holos/pkg/cli"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/rogpeppe/go-internal/testscript"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const clientsetKey = "clientset"
|
||||
|
||||
var secret = v1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Secret",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "k2-talos",
|
||||
Namespace: "secrets",
|
||||
Labels: map[string]string{
|
||||
"holos.run/owner.name": "jeff",
|
||||
"holos.run/secret.name": "k2-talos",
|
||||
},
|
||||
CreationTimestamp: metav1.Time{
|
||||
Time: time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"secrets.yaml": []byte("content: secret\n"),
|
||||
},
|
||||
Type: "Opaque",
|
||||
}
|
||||
|
||||
// cmdHolos executes the holos root command with a kubernetes.Interface that
|
||||
// persists for the duration of the testscript. holos is NOT executed in a
|
||||
// subprocess, the current working directory is not and should not be changed.
|
||||
// Take care to read and write to $WORK in the test scripts using flags.
|
||||
func cmdHolos(ts *testscript.TestScript, neg bool, args []string) {
|
||||
clientset, ok := ts.Value(clientsetKey).(kubernetes.Interface)
|
||||
if clientset == nil || !ok {
|
||||
ts.Fatalf("missing kubernetes.Interface")
|
||||
}
|
||||
|
||||
cfg := holos.New(
|
||||
holos.ProvisionerClientset(clientset),
|
||||
holos.Stdout(ts.Stdout()),
|
||||
holos.Stderr(ts.Stderr()),
|
||||
)
|
||||
|
||||
cmd := cli.New(cfg)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if neg {
|
||||
if err == nil {
|
||||
ts.Fatalf("want: error\nhave: %v", err)
|
||||
} else {
|
||||
ts.Logf("want: error\nhave: %v", err)
|
||||
}
|
||||
} else {
|
||||
ts.Check(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecrets(t *testing.T) {
|
||||
// Add TestWork: true to the Params to keep the $WORK directory around.
|
||||
testscript.Run(t, testscript.Params{
|
||||
Dir: "testdata",
|
||||
Setup: func(env *testscript.Env) error {
|
||||
env.Values[clientsetKey] = fake.NewSimpleClientset(&secret)
|
||||
return nil
|
||||
},
|
||||
Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){
|
||||
"holos": cmdHolos,
|
||||
},
|
||||
})
|
||||
}
|
||||
21
pkg/cli/secret/testdata/create_secret_dry_run.txt
vendored
Normal file
21
pkg/cli/secret/testdata/create_secret_dry_run.txt
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Create the secret
|
||||
holos create secret directory --from-file=$WORK/fixture --dry-run
|
||||
|
||||
# Want no warnings.
|
||||
! stderr 'WRN'
|
||||
|
||||
# Want the data keys
|
||||
stdout 'one.yaml: Y29udGVudDogb25lCg=='
|
||||
stdout 'two.yaml: Y29udGVudDogdHdvCg=='
|
||||
|
||||
# Want the secret name label.
|
||||
stdout 'holos.run/secret.name: directory'
|
||||
|
||||
# Want the TypeMeta
|
||||
stdout 'kind: Secret'
|
||||
stdout 'apiVersion: v1'
|
||||
|
||||
-- fixture/one.yaml --
|
||||
content: one
|
||||
-- fixture/two.yaml --
|
||||
content: two
|
||||
22
pkg/cli/secret/testdata/create_secret_from_dir.txt
vendored
Normal file
22
pkg/cli/secret/testdata/create_secret_from_dir.txt
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Create the secret
|
||||
holos create secret directory --from-file=$WORK/want
|
||||
stderr 'created: directory-..........'
|
||||
stderr 'secret=directory-..........'
|
||||
stderr 'name=directory'
|
||||
stderr 'namespace=secrets'
|
||||
! stderr 'WRN'
|
||||
|
||||
# Get the secret back
|
||||
mkdir have
|
||||
holos get secret directory --extract-all --extract-to=$WORK/have
|
||||
stderr 'wrote: .*/have/one.yaml'
|
||||
stderr 'wrote: .*/have/two.yaml'
|
||||
|
||||
# Compare the secrets
|
||||
cmp want/one.yaml have/one.yaml
|
||||
cmp want/two.yaml have/two.yaml
|
||||
|
||||
-- want/one.yaml --
|
||||
content: one
|
||||
-- want/two.yaml --
|
||||
content: two
|
||||
14
pkg/cli/secret/testdata/create_secret_from_file.txt
vendored
Normal file
14
pkg/cli/secret/testdata/create_secret_from_file.txt
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Create the secret.
|
||||
holos create secret k3-talos --from-file $WORK/secrets.yaml
|
||||
|
||||
# Want info log attributes.
|
||||
stderr 'created: k3-talos-..........'
|
||||
stderr 'secret=k3-talos-..........'
|
||||
stderr 'name=k3-talos'
|
||||
stderr 'namespace=secrets'
|
||||
|
||||
# Want no warnings.
|
||||
! stderr 'WRN'
|
||||
|
||||
-- secrets.yaml --
|
||||
content: hello
|
||||
14
pkg/cli/secret/testdata/create_secret_namespace.txt
vendored
Normal file
14
pkg/cli/secret/testdata/create_secret_namespace.txt
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Create the secret.
|
||||
holos create secret k3-talos --namespace=jeff --from-file $WORK/secrets.yaml
|
||||
stderr 'created: k3-talos-..........'
|
||||
stderr 'secret=k3-talos-..........'
|
||||
stderr 'name=k3-talos'
|
||||
|
||||
# Want specified namespace.
|
||||
stderr 'namespace=jeff'
|
||||
|
||||
# Want no warnings.
|
||||
! stderr 'WRN'
|
||||
|
||||
-- secrets.yaml --
|
||||
content: hello
|
||||
24
pkg/cli/secret/testdata/create_secret_no_depth.txt
vendored
Normal file
24
pkg/cli/secret/testdata/create_secret_no_depth.txt
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Create the secret
|
||||
holos create secret directory --from-file=$WORK/want
|
||||
|
||||
# Get the secret back
|
||||
mkdir have
|
||||
holos get secret directory --extract-all --extract-to=$WORK/have
|
||||
stderr 'wrote: .*/have/one.yaml'
|
||||
stderr 'wrote: .*/have/two.yaml'
|
||||
! stderr 'wrote: .*omit.yaml'
|
||||
|
||||
# Compare the secrets
|
||||
cmp want/one.yaml have/one.yaml
|
||||
cmp want/two.yaml have/two.yaml
|
||||
|
||||
# Want no files with depth > 1
|
||||
! exists have/nope/omit.yaml
|
||||
! exists have/omit.yaml
|
||||
|
||||
-- want/one.yaml --
|
||||
content: one
|
||||
-- want/two.yaml --
|
||||
content: two
|
||||
-- want/nope/omit.yaml --
|
||||
content: not included
|
||||
11
pkg/cli/secret/testdata/create_secret_warns.txt
vendored
Normal file
11
pkg/cli/secret/testdata/create_secret_warns.txt
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Create the secret.
|
||||
holos create secret k3-talos --cluster-name=k2 --from-file $WORK/secrets.yaml
|
||||
stderr 'created: k3-talos-..........'
|
||||
|
||||
# Want a warning about the cluster name prefix.
|
||||
stderr 'missing cluster name prefix'
|
||||
stderr 'have=k3-talos'
|
||||
stderr 'want=k2-'
|
||||
|
||||
-- secrets.yaml --
|
||||
content: hello
|
||||
10
pkg/cli/secret/testdata/get_secret_extract_all.txt
vendored
Normal file
10
pkg/cli/secret/testdata/get_secret_extract_all.txt
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Get and extract the secret
|
||||
holos get secrets k2-talos --extract-all --extract-to=$WORK
|
||||
! stdout .
|
||||
stderr 'wrote: .*/secrets\.yaml'
|
||||
|
||||
# Check the secret keys
|
||||
cmp want.secrets.yaml secrets.yaml
|
||||
|
||||
-- want.secrets.yaml --
|
||||
content: secret
|
||||
3
pkg/cli/secret/testdata/get_secret_print.txt
vendored
Normal file
3
pkg/cli/secret/testdata/get_secret_print.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
holos get secrets k2-talos --print-key=secrets.yaml
|
||||
stdout -count=1 '^content: secret$'
|
||||
! stderr .
|
||||
3
pkg/cli/secret/testdata/get_secrets.txt
vendored
Normal file
3
pkg/cli/secret/testdata/get_secrets.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
holos get secrets
|
||||
stdout '^k2-talos$'
|
||||
! stderr .
|
||||
@@ -4,19 +4,19 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/holos-run/holos/pkg/cli/command"
|
||||
"github.com/holos-run/holos/pkg/config"
|
||||
"github.com/holos-run/holos/pkg/holos"
|
||||
"github.com/holos-run/holos/pkg/util"
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/tools/txtar"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// New returns a new txtar command.
|
||||
func New(cfg *config.Config) *cobra.Command {
|
||||
func New(cfg *holos.Config) *cobra.Command {
|
||||
cmd := command.New("txtar")
|
||||
cmd.Short = "trivial text-based file archives"
|
||||
cmd.Long = "writes arguments to stdout otherwise extracts"
|
||||
@@ -27,7 +27,7 @@ func New(cfg *config.Config) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func makeRunFunc(cfg *config.Config) command.RunFunc {
|
||||
func makeRunFunc(cfg *holos.Config) command.RunFunc {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
// extract an archive
|
||||
if len(args) == 0 {
|
||||
@@ -36,7 +36,7 @@ func makeRunFunc(cfg *config.Config) command.RunFunc {
|
||||
// create an archive
|
||||
a := &txtar.Archive{}
|
||||
for _, name := range args {
|
||||
if err := filepath.WalkDir(name, makeWalkFunc(a)); err != nil {
|
||||
if err := filepath.WalkDir(name, util.MakeWalkFunc(a)); err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
}
|
||||
@@ -47,32 +47,8 @@ func makeRunFunc(cfg *config.Config) command.RunFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func makeWalkFunc(a *txtar.Archive) fs.WalkDirFunc {
|
||||
return func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
|
||||
if !d.IsDir() {
|
||||
if file, err := file(path); err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
} else {
|
||||
a.Files = append(a.Files, file)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func file(path string) (file txtar.File, err error) {
|
||||
file.Name = path
|
||||
file.Data, err = os.ReadFile(path)
|
||||
return
|
||||
}
|
||||
|
||||
// extract files from the configured Stdin to Stdout or the filesystem.
|
||||
func extract(cfg *config.Config) error {
|
||||
func extract(cfg *holos.Config) error {
|
||||
input, err := io.ReadAll(cfg.Stdin())
|
||||
if err != nil {
|
||||
return wrapper.Wrap(fmt.Errorf("could not read stdin: %w", err))
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package config
|
||||
package holos
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/holos-run/holos/pkg/logger"
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"io"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
"k8s.io/client-go/util/homedir"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -18,9 +21,11 @@ const DefaultProvisionerNamespace = "secrets"
|
||||
type Option func(o *options)
|
||||
|
||||
type options struct {
|
||||
stdin io.Reader
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
stdin io.Reader
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
provisionerClientset kubernetes.Interface
|
||||
clientset kubernetes.Interface
|
||||
}
|
||||
|
||||
// Stdin redirects standard input to r, useful for test capture.
|
||||
@@ -38,6 +43,16 @@ func Stderr(w io.Writer) Option {
|
||||
return func(o *options) { o.stderr = w }
|
||||
}
|
||||
|
||||
// ProvisionerClientset sets the kubernetes Clientset, useful for test fake.
|
||||
func ProvisionerClientset(clientset kubernetes.Interface) Option {
|
||||
return func(o *options) { o.provisionerClientset = clientset }
|
||||
}
|
||||
|
||||
// ClusterClientset sets the kubernetes Clientset, useful for test fake.
|
||||
func ClusterClientset(clientset *kubernetes.Clientset) Option {
|
||||
return func(o *options) { o.clientset = clientset }
|
||||
}
|
||||
|
||||
// New returns a new top level cli Config.
|
||||
func New(opts ...Option) *Config {
|
||||
cfgOptions := &options{
|
||||
@@ -53,14 +68,15 @@ func New(opts ...Option) *Config {
|
||||
kvFlagSet := flag.NewFlagSet("", flag.ContinueOnError)
|
||||
txFlagSet := flag.NewFlagSet("", flag.ContinueOnError)
|
||||
cfg := &Config{
|
||||
logConfig: logger.NewConfig(),
|
||||
writeTo: getenv("HOLOS_WRITE_TO", "deploy"),
|
||||
clusterName: getenv("HOLOS_CLUSTER_NAME", ""),
|
||||
writeFlagSet: writeFlagSet,
|
||||
clusterFlagSet: clusterFlagSet,
|
||||
options: cfgOptions,
|
||||
kvFlagSet: kvFlagSet,
|
||||
txtarFlagSet: txFlagSet,
|
||||
logConfig: logger.NewConfig(),
|
||||
writeTo: getenv("HOLOS_WRITE_TO", "deploy"),
|
||||
clusterName: getenv("HOLOS_CLUSTER_NAME", ""),
|
||||
writeFlagSet: writeFlagSet,
|
||||
clusterFlagSet: clusterFlagSet,
|
||||
options: cfgOptions,
|
||||
kvFlagSet: kvFlagSet,
|
||||
txtarFlagSet: txFlagSet,
|
||||
provisionerClientset: cfgOptions.provisionerClientset,
|
||||
}
|
||||
writeFlagSet.StringVar(&cfg.writeTo, "write-to", cfg.writeTo, "write to directory")
|
||||
clusterFlagSet.StringVar(&cfg.clusterName, "cluster-name", cfg.clusterName, "cluster name")
|
||||
@@ -80,19 +96,20 @@ func New(opts ...Option) *Config {
|
||||
// should be initialized early at a well known location in the program lifecycle
|
||||
// then remain immutable.
|
||||
type Config struct {
|
||||
logConfig *logger.Config
|
||||
writeTo string
|
||||
clusterName string
|
||||
logger *slog.Logger
|
||||
options *options
|
||||
finalized bool
|
||||
writeFlagSet *flag.FlagSet
|
||||
clusterFlagSet *flag.FlagSet
|
||||
kvKubeconfig *string
|
||||
kvNamespace *string
|
||||
kvFlagSet *flag.FlagSet
|
||||
txtarIndex *int
|
||||
txtarFlagSet *flag.FlagSet
|
||||
logConfig *logger.Config
|
||||
writeTo string
|
||||
clusterName string
|
||||
logger *slog.Logger
|
||||
options *options
|
||||
finalized bool
|
||||
writeFlagSet *flag.FlagSet
|
||||
clusterFlagSet *flag.FlagSet
|
||||
kvKubeconfig *string
|
||||
kvNamespace *string
|
||||
kvFlagSet *flag.FlagSet
|
||||
txtarIndex *int
|
||||
txtarFlagSet *flag.FlagSet
|
||||
provisionerClientset kubernetes.Interface
|
||||
}
|
||||
|
||||
// LogFlagSet returns the logging *flag.FlagSet for use by the command handler.
|
||||
@@ -224,6 +241,22 @@ func (c *Config) TxtarIndex() int {
|
||||
return *c.txtarIndex
|
||||
}
|
||||
|
||||
// ProvisionerClientset returns a kubernetes client set for the provisioner cluster.
|
||||
func (c *Config) ProvisionerClientset() (kubernetes.Interface, error) {
|
||||
if c.provisionerClientset == nil {
|
||||
kcfg, err := clientcmd.BuildConfigFromFlags("", c.KVKubeconfig())
|
||||
if err != nil {
|
||||
return nil, wrapper.Wrap(err)
|
||||
}
|
||||
clientset, err := kubernetes.NewForConfig(kcfg)
|
||||
if err != nil {
|
||||
return nil, wrapper.Wrap(err)
|
||||
}
|
||||
c.provisionerClientset = clientset
|
||||
}
|
||||
return c.provisionerClientset, nil
|
||||
}
|
||||
|
||||
// getenv is equivalent to os.LookupEnv with a default value.
|
||||
func getenv(key, defaultValue string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
@@ -1,4 +1,4 @@
|
||||
package config
|
||||
package holos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
22
pkg/holos/types.go
Normal file
22
pkg/holos/types.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package holos
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StringSlice represents zero or more flag values.
|
||||
type StringSlice []string
|
||||
|
||||
// String implements the flag.Value interface.
|
||||
func (i *StringSlice) String() string {
|
||||
return fmt.Sprint(*i)
|
||||
}
|
||||
|
||||
// Set implements the flag.Value interface.
|
||||
func (i *StringSlice) Set(value string) error {
|
||||
for _, str := range strings.Split(value, ",") {
|
||||
*i = append(*i, str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
32
pkg/util/txtar.go
Normal file
32
pkg/util/txtar.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"github.com/holos-run/holos/pkg/wrapper"
|
||||
"golang.org/x/tools/txtar"
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
func MakeWalkFunc(a *txtar.Archive) fs.WalkDirFunc {
|
||||
return func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
}
|
||||
|
||||
if !d.IsDir() {
|
||||
if file, err := file(path); err != nil {
|
||||
return wrapper.Wrap(err)
|
||||
} else {
|
||||
a.Files = append(a.Files, file)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func file(path string) (file txtar.File, err error) {
|
||||
file.Name = path
|
||||
file.Data, err = os.ReadFile(path)
|
||||
return
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
43
|
||||
45
|
||||
|
||||
@@ -1 +1 @@
|
||||
2
|
||||
3
|
||||
|
||||
Reference in New Issue
Block a user