Compare commits

..

10 Commits

Author SHA1 Message Date
Jeff McCune
ac5bff4b32 (#20) Error if secret is not found
Without this patch scripts incorrectly proceeded without detecting a
secret was not fetched.

    holos get secret notfound

    8:34AM ERR could not execute version=0.46.3 err="not found: notfound" loc=get.go:66
2024-02-28 08:33:55 -08:00
Jeff McCune
6090ab224e (#14) Validate secrets fetched from provisioner cluster
This patch validates secrets are synced from the provisioner cluster to
a workload cluster.  This verifies the eso-creds-refresher job, external
secrets operator, etc...

Refer to
0ae58858f5
for the corresponding commit on the k2 cluster.
2024-02-27 15:55:17 -08:00
Jeff McCune
10e140258d (#15) Report multiple cue errors
This patch prints out the cue file and line numbers when a cue error
contains multiple go errors to unwrap.

For example:

```
❯ holos render --cluster-name=k2 ~/workspace/holos-run/holos/docs/examples/platforms/reference/clusters/workload/...
3:31PM ERR could not execute version=0.46.0 err="could not decode: content: error in call to encoding/yaml.MarshalStream: incomplete value string (and 1 more errors)" loc=builder.go:212
content: error in call to encoding/yaml.MarshalStream: incomplete value string:
    /home/jeff/workspace/holos-run/holos/docs/examples/schema.cue:199:11
    /home/jeff/workspace/holos-run/holos/docs/examples/cue.mod/gen/external-secrets.io/externalsecret/v1beta1/types_gen.cue:83:14
```
2024-02-27 15:32:11 -08:00
Jeff McCune
40ac705f0d (#16) Add create secret --append-hash=false
So we can easily create secrets for use with ExternalSecret resources.
2024-02-27 12:04:00 -08:00
Jeff McCune
b4ad6425e5 (#14) Validate SecretStore works
This patch validates a SecretStore in the holos-system namespace works
after provisioner credentials are refreshed.
2024-02-27 11:25:00 -08:00
Jeff McCune
3343d226e5 (#14) Fix namespaces "external-secrets" not found
Needed for the `prod-secrets-eso` component to reconcile with flux.

NAME                                    REVISION                SUSPENDED       READY   MESSAGE
flux-system                             main@sha1:28b9ab6b      False           True    Applied revision: main@sha1:28b9ab6b
prod-secrets-eso                        main@sha1:28b9ab6b      False           True    Applied revision: main@sha1:28b9ab6b
prod-secrets-eso-creds-refresher        main@sha1:28b9ab6b      False           True    Applied revision: main@sha1:28b9ab6b
prod-secrets-namespaces                 main@sha1:28b9ab6b      False           True    Applied revision: main@sha1:28b9ab6b
2024-02-26 20:53:43 -08:00
Jeff McCune
f3a9b7cfbc (#10) Additional test coverage for secrets
Also fix a bug, secrets were created with keys that have a sub-directory
which is not a valid kubernetes secret.
2024-02-26 16:58:38 -08:00
Jeff McCune
53b7246d5e (#10) Add tests for holos get secrets command
This patch adds basic test data to run integration level tests on the
holos cli command.  Tests are structured similar to how the go and cue
maintainers test their own cli tools using the testscripts package.

Fixture data is loaded into a fake kubernetes.Clientset.

The holos root command is executed without using a full sub-process so
the fake kubernetes interface persists across multiple holos commands in
the same test case.

The fake kubernetes interface is reset after the testcase script
concludes and a new one starts.

Take care to read and write absolute paths from the test scripts, the
current working directory of the test runner is not set to $WORK when
executing the custom holos command.
2024-02-26 16:16:27 -08:00
Jeff McCune
c20872c92f v0.45.1 2024-02-24 11:37:03 -08:00
Jeff McCune
ecce1f797e (#8) Get secret subcommand
This patch adds a get secret subcommand.  With no args, lists holos
secrets.  With args, gets each argument.

The use cases are:

 1. Extract specified keys to files with --to-file
 2. Extract all keys to files with --extract-all
 3. Print one key to stdout with --print-key

If no key is specified, the key is implicitly set to the holos secret
name.  This behavior should be preserved as part of the api.
2024-02-24 11:32:48 -08:00
38 changed files with 732 additions and 207 deletions

View File

@@ -1,28 +1,10 @@
package main
import (
"context"
"errors"
"github.com/holos-run/holos/pkg/cli"
"github.com/holos-run/holos/pkg/holos"
"github.com/holos-run/holos/pkg/wrapper"
"log/slog"
"os"
)
func main() {
cfg := holos.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
View 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",
})
}

View File

@@ -0,0 +1,15 @@
# Want cue errors to show files and lines
! exec holos build .
stderr 'could not decode: content: cannot convert non-concrete value string'
stderr '/component.cue:6:1$'
-- cue.mod --
package holos
-- component.cue --
package holos
apiVersion: "holos.run/v1alpha1"
kind: "KubernetesObjects"
cluster: string @tag(cluster, string)
content: foo
foo: string

5
cmd/holos/testdata/version.txt vendored Normal file
View 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 .

View File

@@ -922,7 +922,7 @@ import (
kubernetes?: {
// Auth configures how secret-manager authenticates with a
// Kubernetes instance.
auth: struct.MaxFields(1) & {
auth: {
// has both clientCert and clientKey as secretKeySelector
cert?: {
// A reference to a specific 'key' within a Secret resource,

View File

@@ -15,6 +15,10 @@ objects: #CredsRefresherService.objects
#TargetNamespace: #CredsRefresher.namespace
#Kustomization: spec: {
dependsOn: [{name: #InstancePrefix + "-namespaces"}]
}
let NAME = #CredsRefresher.name
let AUD = "//iam.googleapis.com/projects/\(#InputKeys.gcpProjectNumber)/locations/global/workloadIdentityPools/holos/providers/k8s-\(#InputKeys.cluster)"
let MOUNT = "/var/run/service-account"

View File

@@ -1,3 +1,30 @@
package holos
// Manages the External Secrets Operator from the official upstream Helm chart.
#TargetNamespace: "external-secrets"
#InputKeys: component: "eso"
#InputKeys: {
project: "secrets"
service: "eso"
}
#Kustomization: spec: {
dependsOn: [{name: #InstancePrefix + "-namespaces"}]
targetNamespace: #TargetNamespace
}
#HelmChart & {
values: installCrds: true
namespace: #TargetNamespace
chart: {
name: "external-secrets"
version: "0.9.12"
repository: {
name: "external-secrets"
url: "https://charts.external-secrets.io"
}
}
}

View File

@@ -1,8 +0,0 @@
package holos
#TargetNamespace: "external-secrets"
#InputKeys: {
project: "secrets"
service: "eso"
}

View File

@@ -1,16 +0,0 @@
package holos
#Kustomization: spec: dependsOn: [{name: #InstancePrefix + "-namespaces"}]
#HelmChart & {
values: installCrds: true
namespace: #TargetNamespace
chart: {
name: "external-secrets"
version: "0.9.12"
repository: {
name: "external-secrets"
url: "https://charts.external-secrets.io"
}
}
}

View File

@@ -0,0 +1,19 @@
package holos
// Validate ESO by syncing a secret with a SecretStore.
#TargetNamespace: "holos-system"
#InputKeys: {
project: "secrets"
component: "validate"
}
#Kustomization: spec: dependsOn: [{name: #InstancePrefix + "-eso"}]
objects: [
#SecretStore,
#ExternalSecret & { _name: "validate" },
]
{} & #KubernetesObjects

View File

@@ -1,6 +1,7 @@
package holos
#PlatformNamespaces: [
{name: "external-secrets"},
{name: "holos-system"},
{name: "flux-system"},
{name: "ceph-system"},

View File

@@ -1,13 +0,0 @@
package holos
#Kustomization: spec: dependsOn: [{name: #InstancePrefix + "-eso"}]
objects: [
#SecretStore,
#ExternalSecret & {
_name: "validate"
spec: dataFrom: [{extract: key: "ns/" + #TargetNamespace + "/test"}]
},
]
{} & #KubernetesObjects

View File

@@ -1,8 +0,0 @@
package holos
#TargetNamespace: "default"
#InputKeys: {
project: "secrets"
component: "validate"
}

View File

@@ -79,8 +79,10 @@ _apiVersion: "holos.run/v1alpha1"
kind: string | *"GitRepository"
name: string | *"flux-system"
}
timeout: string | *"3m0s"
wait: bool | *true
suspend?: bool
targetNamespace?: string
timeout: string | *"3m0s"
wait: bool | *true
}
}
@@ -88,7 +90,7 @@ _apiVersion: "holos.run/v1alpha1"
#ExternalSecret: #NamespaceObject & es.#ExternalSecret & {
_name: string
metadata: {
namespace: string | *"default"
namespace: #TargetNamespace
name: _name
}
spec: {
@@ -100,24 +102,29 @@ _apiVersion: "holos.run/v1alpha1"
target: {
creationPolicy: string | *"Owner"
}
data: [{
remoteRef: key: _name
secretKey: _name
}]
}
}
#SecretStore: #NamespaceObject & ss.#SecretStore & {
metadata: {
name: string | *"default"
namespace: string | *#TargetNamespace
namespace: #TargetNamespace
}
spec: provider: {
vault: {
auth: kubernetes: {
mountPath: #InputKeys.cluster
role: string | *"default"
serviceAccountRef: name: string | *"default"
kubernetes: {
remoteNamespace: #TargetNamespace
auth: token: bearerToken: {
name: string | *"eso-reader"
key: string | *"token"
}
server: {
caBundle: #InputKeys.provisionerCABundle
url: #InputKeys.provisionerURL
}
path: string | *"kv/k8s"
server: "https://vault.core." + #Platform.org.domain
version: string | *"v2"
}
}
}
@@ -138,6 +145,11 @@ _apiVersion: "holos.run/v1alpha1"
// GCP Project Info used for the Provisioner Cluster
gcpProjectID: string @tag(gcpProjectID, type=string)
gcpProjectNumber: int @tag(gcpProjectNumber, type=int)
// Same as cluster certificate-authority-data field in ~/.holos/kubeconfig.provisioner
provisionerCABundle: string @tag(provisionerCABundle, type=string)
// Same as the cluster server field in ~/.holos/kubeconfig.provisioner
provisionerURL: string @tag(provisionerURL, type=string)
}
// #Platform defines the primary lookup table for the platform. Lookup keys should be limited to those defined in #KeyTags.

3
go.mod
View File

@@ -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

6
go.sum
View File

@@ -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=

23
pkg/cli/get/get.go Normal file
View 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
}

42
pkg/cli/main.go Normal file
View File

@@ -0,0 +1,42 @@
package cli
import (
"context"
"cuelang.org/go/cue/errors"
"fmt"
"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 cueErr errors.Error
var errAt *wrapper.ErrorAt
const msg = "could not execute"
if errors.As(err, &errAt) {
log.ErrorContext(ctx, msg, "err", errAt.Unwrap(), "loc", errAt.Source.Loc())
} else {
log.ErrorContext(ctx, msg, "err", err)
}
// cue errors are bundled up as a list and refer to multiple files / lines.
if errors.As(err, &cueErr) {
msg := errors.Details(cueErr, nil)
_, _ = fmt.Fprint(hc.Stderr(), msg)
}
return 1
}

View File

@@ -3,6 +3,7 @@ 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"
@@ -48,6 +49,7 @@ func New(cfg *holos.Config) *cobra.Command {
// subcommands
rootCmd.AddCommand(build.New(cfg))
rootCmd.AddCommand(render.New(cfg))
rootCmd.AddCommand(get.New(cfg))
rootCmd.AddCommand(create.New(cfg))
// Maybe not needed?

131
pkg/cli/secret/create.go Normal file
View File

@@ -0,0 +1,131 @@
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")
cfg.appendHash = flagSet.Bool("append-hash", true, "append hash to kubernetes secret name")
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 *cfg.appendHash {
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
View 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 {
return wrapper.Wrap(fmt.Errorf("not found: %v", secretName))
}
// 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
}

View File

@@ -2,132 +2,30 @@ package secret
import (
"flag"
"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"
)
const NameLabel = "holos.run/secret.name"
const OwnerLabel = "holos.run/secret.owner"
const OwnerLabel = "holos.run/owner.name"
const ClusterLabel = "holos.run/cluster.name"
type secretData map[string][]byte
type config struct {
files holos.StringSlice
dryRun *bool
cluster *string
namespace *string
files holos.StringSlice
printFile *string
extract *bool
dryRun *bool
appendHash *bool
cluster *string
namespace *string
extractTo *string
}
func NewCreateCmd(hc *holos.Config) *cobra.Command {
cmd := command.New("secret NAME [--from-file=source]")
cmd.Args = cobra.ExactArgs(1)
cmd.Short = "Create a holos secret from files or directories"
cmd.Flags().SortFlags = false
func newConfig() (*config, *flag.FlagSet) {
cfg := &config{}
flagSet := flag.NewFlagSet("", flag.ContinueOnError)
flagSet.Var(&cfg.files, "from-file", "store files as keys in the secret")
cfg.namespace = flagSet.String("namespace", holos.DefaultProvisionerNamespace, "namespace in the provisioner cluster where the secret is created")
cfg.cluster = flagSet.String("cluster-name", "", "cluster name")
cfg.dryRun = flagSet.Bool("dry-run", false, "dry run")
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 {
secretName := args[0]
secret := &v1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
Labels: map[string]string{NameLabel: secretName},
},
Data: make(secretData),
}
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)
}
ctx := cmd.Context()
secret, err = cs.CoreV1().
Secrets(*cfg.namespace).
Create(ctx, secret, metav1.CreateOptions{})
if err != nil {
return wrapper.Wrap(err)
}
log := logger.FromContext(ctx)
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 {
if d.IsDir() {
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
}
cfg.namespace = flagSet.String("namespace", holos.DefaultProvisionerNamespace, "namespace in the provisioner cluster")
cfg.cluster = flagSet.String("cluster-name", "", "cluster name selector")
return cfg, flagSet
}

View File

@@ -0,0 +1,82 @@
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("\nwant: error\nhave: %v", err)
} else {
cli.HandleError(cmd.Context(), err, cfg)
}
} 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,
},
})
}

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,7 @@
# Want no hash appended
holos create secret test --namespace holos-system --from-file $WORK/test --append-hash=false
stderr ' created: test '
stderr ' secret=test '
-- test --
sekret

View File

@@ -0,0 +1,6 @@
# Want no hash appended
holos create secret test --namespace holos-system --from-file $WORK/test --append-hash=false --dry-run
stdout 'name: test$'
-- test --
sekret

View 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

View 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

View File

@@ -0,0 +1,3 @@
holos get secrets k2-talos --print-key=secrets.yaml
stdout -count=1 '^content: secret$'
! stderr .

View File

@@ -0,0 +1,3 @@
holos get secrets
stdout '^k2-talos$'
! stderr .

View File

@@ -0,0 +1,3 @@
# Want cue errors to show files and lines
! holos get secret does-not-exist
stderr 'not found: does-not-exist'

View File

@@ -21,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.
@@ -41,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{
@@ -56,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")
@@ -96,7 +109,7 @@ type Config struct {
kvFlagSet *flag.FlagSet
txtarIndex *int
txtarFlagSet *flag.FlagSet
provisionerClientset *kubernetes.Clientset
provisionerClientset kubernetes.Interface
}
// LogFlagSet returns the logging *flag.FlagSet for use by the command handler.
@@ -229,7 +242,7 @@ func (c *Config) TxtarIndex() int {
}
// ProvisionerClientset returns a kubernetes client set for the provisioner cluster.
func (c *Config) ProvisionerClientset() (*kubernetes.Clientset, error) {
func (c *Config) ProvisionerClientset() (kubernetes.Interface, error) {
if c.provisionerClientset == nil {
kcfg, err := clientcmd.BuildConfigFromFlags("", c.KVKubeconfig())
if err != nil {

View File

@@ -1 +1 @@
45
46

View File

@@ -1 +1 @@
0
3