From 9e6c5bfef798c00e7cf9d15697823ea5d3332b70 Mon Sep 17 00:00:00 2001 From: Ross Kirkpatrick Date: Sun, 1 Mar 2026 23:29:43 -0500 Subject: [PATCH] add age crypto to github backend Signed-off-by: Ross Kirkpatrick --- .golangci.yml | 153 ++++--- Makefile | 4 +- README.md | 49 ++ cloudinit/cloudinit.go | 115 +++-- cloudinit/cloudinit_test.go | 16 +- cloudinit/files/capi-operator.yaml | 9 +- cloudinit/files/cert-manager.yaml | 1 + cloudinit/iofs_test.go | 9 +- cmd/cluster.go | 16 +- cmd/decrypt.go | 60 +++ cmd/delete.go | 6 +- cmd/encrypt.go | 68 +++ cmd/get.go | 2 +- cmd/get_kubeconfig.go | 2 +- cmd/help.go | 2 +- cmd/kubectl.go | 6 +- cmd/list.go | 2 +- cmd/put.go | 2 +- cmd/root.go | 5 +- cmd/version.go | 2 +- go.mod | 25 +- go.sum | 54 ++- providers/backend/backend.go | 6 +- providers/backend/backend_test.go | 6 +- providers/backend/github/github.go | 423 +++++++++++++++--- providers/backend/mock/mock_types.go | 97 +++- providers/backend/s3/s3.go | 37 +- providers/backend/s3/s3_test.go | 12 +- providers/backend/types.go | 15 +- .../controlplane/k3s/files/capi-k3s.yaml | 6 +- providers/controlplane/k3s/k3s.go | 13 +- .../linode/files/capi-linode.yaml | 4 +- .../linode/files/linode-ccm-vpc.yaml | 3 +- .../linode/files/linode-ccm.yaml | 2 +- providers/infrastructure/linode/linode.go | 30 +- state/types.go | 20 +- types/types.go | 4 +- utils/crypto.go | 161 +++++++ utils/crypto_test.go | 132 ++++++ utils/list.go | 2 +- yaml/files_test.go | 8 +- yaml/types.go | 73 ++- 42 files changed, 1352 insertions(+), 310 deletions(-) create mode 100644 cmd/decrypt.go create mode 100644 cmd/encrypt.go create mode 100644 utils/crypto.go create mode 100644 utils/crypto_test.go diff --git a/.golangci.yml b/.golangci.yml index 84cf6df..08f19e4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,8 @@ +# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json +version: "2" run: timeout: 10m - go: "1.22" + go: "1.24" allow-parallel-runners: true linters: @@ -16,16 +18,13 @@ linters: - durationcheck - errcheck - errchkjson - - gci - ginkgolinter - goconst - gocritic - godot - - gofmt - - goimports - goprintffuncname - gosec - - gosimple +# - gosimple - govet - importas - ineffassign @@ -43,81 +42,87 @@ linters: # - revive - rowserrcheck - staticcheck - - stylecheck - - tenv - - typecheck - unconvert - unparam - unused - usestdlibvars - whitespace -linters-settings: - gci: - sections: - - standard # Standard section: captures all standard packages. - - default # Default section: contains all imports that could not be matched to another section type. - - prefix(capi-bootstrap) # Custom section: groups all imports with the specified Prefix. - gocritic: - enabled-tags: - - diagnostic - - experimental - - performance - disabled-checks: - - appendAssign - - dupImport # https://github.com/go-critic/go-critic/issues/845 - - evalOrder - - ifElseChain - - octalLiteral - - regexpSimplify - - sloppyReassign - - truncateCmp - - typeDefFirst - - unnamedResult - - unnecessaryDefer - - whyNoLint - - wrapperFunc - - rangeValCopy - - hugeParam - revive: - rules: - # The following rules are recommended https://github.com/mgechev/revive#recommended-configuration - - name: blank-imports - - name: context-as-argument - - name: context-keys-type - - name: dot-imports - - name: error-return - - name: error-strings - - name: error-naming - - name: exported - - name: if-return - - name: increment-decrement - - name: var-naming - - name: var-declaration - - name: package-comments - - name: range - - name: receiver-naming - - name: time-naming - - name: unexported-return - - name: indent-error-flow - - name: errorf - - name: empty-block - - name: superfluous-else - - name: unused-parameter - - name: unreachable-code - - name: redefines-builtin-id - # - # Rules in addition to the recommended configuration above. - # - - name: bool-literal-in-expr - - name: constant-logical-expr - gosec: - excludes: - - G306 # Poor file permissions used when writing to a new file - goimports: - # put imports beginning with prefix after 3rd-party packages; - # it's a comma-separated list of prefixes - local-prefixes: capi-bootstrap + settings: + gocritic: + enabled-tags: + - diagnostic + - experimental + - performance + disabled-checks: + - appendAssign + - dupImport # https://github.com/go-critic/go-critic/issues/845 + - evalOrder + - ifElseChain + - octalLiteral + - regexpSimplify + - sloppyReassign + - truncateCmp + - typeDefFirst + - unnamedResult + - unnecessaryDefer + - whyNoLint + - wrapperFunc + - rangeValCopy + - hugeParam + revive: + rules: + # The following rules are recommended https://github.com/mgechev/revive#recommended-configuration + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unused-parameter + - name: unreachable-code + - name: redefines-builtin-id + # + # Rules in addition to the recommended configuration above. + # + - name: bool-literal-in-expr + - name: constant-logical-expr + gosec: + excludes: + - G306 # Poor file permissions used when writing to a new file + +formatters: + enable: + - gci + - goimports + - gofmt + - goimports + settings: + gci: + sections: + - standard # Standard section: captures all standard packages. + - default # Default section: contains all imports that could not be matched to another section type. + - prefix(capi-bootstrap) # Custom section: groups all imports with the specified Prefix. + goimports: + # put imports beginning with prefix after 3rd-party packages; + # it's a comma-separated list of prefixes + local-prefixes: + - capi-bootstrap issues: max-same-issues: 0 max-issues-per-linter: 0 diff --git a/Makefile b/Makefile index 2ac48b9..7a1eba7 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ BUILD_DIR ?= bin BUILD_TARGET ?= $(BUILD_DIR)/clusterctl-bootstrap -GOLANGCI_LINT_VERSION ?= v1.60.1 +GOLANGCI_LINT_VERSION ?= v2.10.1 MOCKGEN_VERSION ?= v0.4.0 GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint-$(GOLANGCI_LINT_VERSION) MOCKGEN ?= $(LOCALBIN)/mockgen-$(MOCKGEN_VERSION) @@ -59,7 +59,7 @@ lint: golangci-lint .PHONY: golangci-lint golangci-lint: $(GOLANGCI_LINT) $(GOLANGCI_LINT): $(LOCALBIN) - $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,${GOLANGCI_LINT_VERSION}) + $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,${GOLANGCI_LINT_VERSION}) .PHONY: mockgen mockgen: $(MOCKGEN) diff --git a/README.md b/README.md index 194e1b3..5c02b11 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Note: Using devbox will install necessary requirements to build/run this project * Golang * clusterctl * kubectl + * (optional) [age](https://github.com/FiloSottile/age) + * required if using cluster state encryption feature ## Getting started Note: if you are using devbox, enter into a shell using `devbox shell` before running these commands to use its integration. @@ -33,7 +35,51 @@ Note: if you are using devbox, enter into a shell using `devbox shell` before ru # I0603 10:12:53.447277 70482 cluster.go:165] Created Linode Instance: test-cluster-bootstrap # I0603 10:12:53.644074 70482 cluster.go:185] Created NodeBalancer Node: test-cluster-bootstrap # I0603 10:12:53.644124 70482 cluster.go:186] Bootstrap Node IP: + ``` + a. If you have enabled the cluster state encryption feature, your output will also include an age key + ```shell + # copy the age key + + # initialize it locally (insecure) + CLUSTER_NAME=ross10 + echo -n 'AGE-SECRET-KEY-PQ-...' > "$HOME/.capi-bootstrap/$CLUSTER_NAME/cluster.age" + + # initialize it in a password management tool (secure) + # 1pass, vault, etc. + export CLUSTER_NAME=ross10 + export CLUSTER_AGE_KEYFILE="/tmp/${CLUSTER_NAME}.age" + echo -n 'AGE-SECRET-KEY-PQ-<>' > "${CLUSTER_AGE_KEYFILE}" + + # 1pass example + op item create --vault="Private" \ + --category='Password' \ + --title="${CLUSTER_NAME} Encryption Key" \ + --tags="capi-bootstrap" \ + 'cluster-name[text]'="${CLUSTER_NAME}" \ + 'encryption-key[password]'="$(cat ${CLUSTER_AGE_KEYFILE})" + + ID: <> + Title: ross10 Encryption Key + Vault: Private (<>) + Created: now + Updated: now + Favorite: false + Tags: capi-bootstrap + Version: 1 + Category: PASSWORD + Fields: + cluster-name: ross10 + encryption-key: [use 'op item get <> --reveal' to reveal] + + # cleanup the temporary key file + rm -f "${CLUSTER_AGE_KEYFILE}" + + # use the key with capi-bootstrap + CLUSTER_AGE_KEY=$(op read -n 'op://Private/ross10 Encryption Key/encryption-key') + export CLUSTER_AGE_KEY + ``` + 4. Get kubeconfig for cluster ```shell clusterctl bootstrap get kubeconfig $CLUSTER_NAME --backend s3 > test-kubeconfig @@ -62,6 +108,7 @@ Note: if you are using devbox, enter into a shell using `devbox shell` before ru # I0603 10:42:35.730360 73227 delete.go:110] Deleted NodeBalancer test-cluster ``` ## Supported providers + ### Infrastructure Providers * [Linode](https://linode.github.io/cluster-api-provider-linode/) * Identifying Resources - Resources used to identify the infrastructure provider from the parsed manifests. @@ -75,12 +122,14 @@ Note: if you are using devbox, enter into a shell using `devbox shell` before ru # used for connecting to machines directly for debug steps export AUTHORIZED_KEYS=$YOUR_PUBLIC_KEY ``` + ### ControlPlane Providers * [K3s](https://github.com/k3s-io/cluster-api-k3s/tree/main) * Identifying resources - Resources used to identify the Controlplane provider from the parsed manifests. * `KthreesControlPlane` * Supported Versions - Supported provider versions for parsing manifests * `v1beta1` + ### Backend Providers * S3 * Environment Variables - Required and optional environment variables used to bootstrap a cluster diff --git a/cloudinit/cloudinit.go b/cloudinit/cloudinit.go index a71685d..7e0dc91 100644 --- a/cloudinit/cloudinit.go +++ b/cloudinit/cloudinit.go @@ -1,18 +1,13 @@ package cloudinit import ( - "archive/tar" - "bytes" - "compress/gzip" "context" "embed" "fmt" - "io" "path" "strings" - "time" - "gopkg.in/yaml.v3" + klog "k8s.io/klog/v2" "capi-bootstrap/providers/backend" "capi-bootstrap/providers/controlplane" @@ -25,10 +20,10 @@ import ( var files embed.FS func GenerateCloudInit(ctx context.Context, values *types.Values, infra infrastructure.Provider, controlPlane controlplane.Provider, backend backend.Provider) ([]byte, error) { - debugCmds := []string{"curl -s -L https://github.com/derailed/k9s/releases/download/v0.32.4/k9s_Linux_amd64.tar.gz | tar -xvz -C /usr/local/bin k9s", + debugCmds := []string{"curl -s -L https://github.com/derailed/k9s/releases/download/v0.50.1/k9s_Linux_amd64.tar.gz | tar -xvz -C /usr/local/bin k9s", `echo "alias k=\"k3s kubectl\"" >> /root/.bashrc`, "echo \"export KUBECONFIG=/etc/rancher/k3s/k3s.yaml\" >> /root/.bashrc"} - initScriptPath := "/tmp/init-cluster.sh" + initScriptPath := fmt.Sprintf("/run/%s/init-cluster.sh", values.ClusterName) certManager, err := generateCertManagerManifest(values) if err != nil { return nil, err @@ -93,6 +88,7 @@ func GenerateCloudInit(ctx context.Context, values *types.Values, infra infrastr runCmds = append(capiManifests.PreRunCmd, runCmds...) runCmds = append(runCmds, capiManifests.PostRunCmd...) + //nolint:prealloc writeFiles := []capiYaml.InitFile{ *certManager, *capiOperator, @@ -108,12 +104,40 @@ func GenerateCloudInit(ctx context.Context, values *types.Values, infra infrastr writeFiles = append(writeFiles, additionalControlPlaneFiles...) writeFiles = append(writeFiles, controlPlaneCertFiles...) writeFiles = append(writeFiles, capiManifests.AdditionalFiles...) + + if values.EncryptedState { + // TODO (rk) clean this up so we don't need to loop over writeFiles twice? + // add EncryptFile() method? + filestoEncrypt := make(map[string]string, len(writeFiles)) + for _, file := range writeFiles { + filestoEncrypt[file.Path] = file.Content + } + + encryptedFiles, err := backend.EncryptFiles(ctx, values.ClusterName, filestoEncrypt) + if err != nil { + return nil, err + } + + for n, file := range writeFiles { + writeFiles[n].Path += ".enc" + writeFiles[n].Content = encryptedFiles[file.Path] + klog.V(4).Infof("updated cluster %s file %s with encrypted content", values.ClusterName, writeFiles[n].Path) + } + + klog.Infof("Encrypted %d files for cluster %s", len(writeFiles), values.ClusterName) + } + if values.TarWriteFiles { - writeFiles, err = createTar(writeFiles) + data, err := capiYaml.TarFromInitFiles(writeFiles, false) if err != nil { return nil, err } - runCmds = append([]string{"tar -C / -xvf /tmp/cloud-init-files.tgz", "tar -xf /tmp/cloud-init-files.tgz --to-command='xargs -0 cloud-init query -f > /$TAR_FILENAME'"}, runCmds...) + + writeFiles = []capiYaml.InitFile{{ + Path: fmt.Sprintf("/run/%s/cloud-init-files.tgz", values.ClusterName), + Content: string(data), + }} + runCmds = append([]string{fmt.Sprintf("tar -C / -xvf /run/%s/cloud-init-files.tgz", values.ClusterName), fmt.Sprintf("tar -xf /run/%s/cloud-init-files.tgz --to-command='xargs -0 cloud-init query -f > /$TAR_FILENAME'", values.ClusterName)}, runCmds...) } cloudConfig := capiYaml.Config{ @@ -124,8 +148,26 @@ func GenerateCloudInit(ctx context.Context, values *types.Values, infra infrastr if err != nil { return nil, err } + if values.EncryptedState { + // ensure that the first download command pulls in age CLI to allow for decryption + downloadCmds = append(downloadCmds[:1], downloadCmds[0:]...) + downloadCmds[0] = "curl -s -L https://github.com/FiloSottile/age/releases/download/v1.3.1/age-v1.3.1-linux-amd64.tar.gz | tar -xvz --strip-components=1 -C /usr/local/bin age/age age/age-inspect" + + err = backend.PrintClusterAgeKey(ctx, values.ClusterName) + if err != nil { + return nil, err + } + } cloudConfig.RunCmd = append(downloadCmds, cloudConfig.RunCmd...) - rawCloudConfig, err := yaml.Marshal(cloudConfig) + + if values.EncryptedState { + // ensure that age key is first file written + // the key is only added to the cloud-config, it is not written to the backend datastore + cloudConfig.WriteFiles = append(cloudConfig.WriteFiles[:1], cloudConfig.WriteFiles[0:]...) + cloudConfig.WriteFiles[0] = *backend.GetClusterAgeKeyInitFile() + klog.Infof("injected cluster %s age key into cloud-init", values.ClusterName) + } + rawCloudConfig, err := capiYaml.Marshal(cloudConfig) if err != nil { return nil, err } @@ -160,57 +202,6 @@ func GenerateCapiManifests(ctx context.Context, values *types.Values, infra infr return capiManifests, nil } -func createTar(cloudFiles []capiYaml.InitFile) ([]capiYaml.InitFile, error) { - data, err := tarFromInitFiles(cloudFiles) - if err != nil { - return nil, err - } - - writeFiles := []capiYaml.InitFile{{ - Path: "/tmp/cloud-init-files.tgz", - Content: string(data), - }} - return writeFiles, nil -} - -func tarFromInitFiles(files []capiYaml.InitFile) (data []byte, err error) { - var buf bytes.Buffer - gzipWriter := gzip.NewWriter(&buf) - tarWriter := tar.NewWriter(gzipWriter) - - defer func() { - err = tarWriter.Close() // close tar writer first - if err != nil { - return - } - err = gzipWriter.Close() // close gzip writer second - if err != nil { - return - } - data, err = io.ReadAll(&buf) // capture all output - }() - - for _, file := range files { - header := &tar.Header{ - Name: file.Path[1:], - Size: int64(len(file.Content)), - ModTime: time.Now(), - Mode: 0o644, - } - err = tarWriter.WriteHeader(header) - if err != nil { - return data, err - } - - _, err = io.WriteString(tarWriter, file.Content) - if err != nil { - return data, err - } - } - - return data, err -} - func UpdateManifest(ctx context.Context, yamlManifest string, infra infrastructure.Provider, controlPlane controlplane.Provider, values *types.Values) ([]byte, *capiYaml.ParsedManifest, error) { manifests := strings.Split(yamlManifest, "---") controlPlaneManifests := &capiYaml.ParsedManifest{} diff --git a/cloudinit/cloudinit_test.go b/cloudinit/cloudinit_test.go index e3e3365..0ba9ff5 100644 --- a/cloudinit/cloudinit_test.go +++ b/cloudinit/cloudinit_test.go @@ -645,7 +645,13 @@ spec: ctx := context.Background() dir, err := os.MkdirTemp("", "example") assert.NoError(t, err) - defer os.RemoveAll(dir) // clean up + defer func(path string) { + err := os.RemoveAll(path) + if err != nil { + t.Errorf("failed to remove temp dir: %v", err) + return + } + }(dir) // clean up tc.value.ManifestFS = os.DirFS(dir) file := filepath.Join(dir, "tmpfile") @@ -786,7 +792,13 @@ spec: ctx := context.Background() dir, err := os.MkdirTemp("", "example") assert.NoError(t, err) - defer os.RemoveAll(dir) // clean up + defer func(path string) { + err := os.RemoveAll(path) + if err != nil { + t.Errorf("failed to remove temp dir: %v", err) + return + } + }(dir) // clean up file := filepath.Join(dir, "tmpfile") err = os.WriteFile(file, []byte(tc.manifest), 0666) assert.NoError(t, err) diff --git a/cloudinit/files/capi-operator.yaml b/cloudinit/files/capi-operator.yaml index bf997bd..0968ad0 100644 --- a/cloudinit/files/capi-operator.yaml +++ b/cloudinit/files/capi-operator.yaml @@ -15,9 +15,14 @@ spec: targetNamespace: capi-operator-system createNamespace: true bootstrap: true + version: v0.23.0 # >=v0.24.0 requires upgrading to cluster-api v1.11.x valuesContent: |- - core: cluster-api - addon: helm + core: + cluster-api: + version: v1.10.5 + addon: + helm: + version: v0.4.0 manager: featureGates: core: diff --git a/cloudinit/files/cert-manager.yaml b/cloudinit/files/cert-manager.yaml index 325a0b9..a903c10 100644 --- a/cloudinit/files/cert-manager.yaml +++ b/cloudinit/files/cert-manager.yaml @@ -7,6 +7,7 @@ metadata: spec: repo: https://charts.jetstack.io chart: cert-manager + version: v1.19.4 targetNamespace: cert-manager createNamespace: true bootstrap: true diff --git a/cloudinit/iofs_test.go b/cloudinit/iofs_test.go index 0906f77..13ddaad 100644 --- a/cloudinit/iofs_test.go +++ b/cloudinit/iofs_test.go @@ -1,6 +1,7 @@ package cloudinit import ( + "io/fs" "os" "testing" @@ -20,7 +21,13 @@ func TestIoFS(t *testing.T) { testFS := IoFS{Reader: os.Stdin} ioFile, err := testFS.Open("stdin") assert.NoError(t, err, "Failed to open file for reading") - defer ioFile.Close() + defer func(ioFile fs.File) { + err := ioFile.Close() + if err != nil { + t.Errorf("failed to close stdin: %v", err) + return + } + }(ioFile) stat, err := ioFile.Stat() assert.NoError(t, err, "Failed to stat file for reading") assert.Equal(t, "|0", stat.Name(), "expected size: ", len(expectedContent)) diff --git a/cmd/cluster.go b/cmd/cluster.go index a844529..5399174 100644 --- a/cmd/cluster.go +++ b/cmd/cluster.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/spf13/cobra" - "k8s.io/klog/v2" + klog "k8s.io/klog/v2" "capi-bootstrap/cloudinit" "capi-bootstrap/providers/backend" @@ -31,6 +31,7 @@ type clusterOptions struct { capi string controlPlane string infrastructure string + encryptState bool manifest string kubernetesVersion string @@ -49,6 +50,9 @@ func init() { clusterCmd.Flags().StringVar(&clusterOpts.kubernetesVersion, "kubernetes-version", "", "The Kubernetes version to use for the workload cluster. If unspecified, the value from OS environment variables or the $XDG_CONFIG_HOME/cluster-api/clusterctl.yaml config file will be used.") + clusterCmd.Flags().BoolVar(&clusterOpts.encryptState, "encrypt", false, + "Whether to encrypt the state when bootstrapping a cluster") + clusterCmd.Flags().Int64Var(&clusterOpts.controlPlaneMachineCount, "control-plane-machine-count", 1, "The number of control plane machines for the workload cluster.") // Remove default from hard coded text if the default is ever changed from 0 since cobra would then add it @@ -118,17 +122,23 @@ func runBootstrapCluster(cmd *cobra.Command, _ []string) error { values.ClusterName = clusterSpec.Name values.ClusterKind = clusterSpec.Spec.InfrastructureRef.Kind - backendProvider := backend.NewProvider(clusterOpts.backend) + if os.Getenv("ENCRYPT_STATE") != "" || clusterOpts.encryptState { + klog.Infof("cluster %s state will be encrypted", values.ClusterName) + values.EncryptedState = true + } + + backendProvider := backend.NewProvider(clusterOpts.backend, clusterOpts.encryptState) if backendProvider == nil { return errors.New("backend provider not specified, options are: " + strings.Join(backend.ListProviders(), ", ")) } + if err := backendProvider.PreCmd(ctx, values.ClusterName); err != nil { return err } _, err = backendProvider.Read(ctx, values.ClusterName) if err == nil { // cluster already exists, don't overwrite it - return errors.New("cluster state already exists in backend, delete before trying again") + return fmt.Errorf("cluster %s state already exists in %s backend, delete before trying again", clusterOpts.backend, values.ClusterName) } if values.ClusterName == "" { diff --git a/cmd/decrypt.go b/cmd/decrypt.go new file mode 100644 index 0000000..dfffa62 --- /dev/null +++ b/cmd/decrypt.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + klog "k8s.io/klog/v2" + + "capi-bootstrap/providers/backend" +) + +var decryptCmd = &cobra.Command{ + Use: "decrypt", + Short: "", + Long: ``, + Args: func(_ *cobra.Command, args []string) error { + if len(args) < 1 { + return fmt.Errorf("please specify a cluster name") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runDecryptFiles(cmd, args[0], args[0:]) + }, +} + +func init() { + // flags for the backend provider + decryptCmd.Flags().StringVar(&clusterOpts.backend, "backend", "", + "The backend provider to use to store configuration for the cluster") + + rootCmd.AddCommand(decryptCmd) +} + +func runDecryptFiles(cmd *cobra.Command, clusterName string, files []string) error { + ctx := cmd.Context() + filesToDecrypt := make(map[string]string, len(files)) + + backendProvider := backend.NewProvider(clusterOpts.backend, clusterOpts.encryptState) + if backendProvider == nil { + return fmt.Errorf("backend provider not specified, options are: %s", strings.Join(backend.ListProviders(), ",")) + } + if err := backendProvider.PreCmd(ctx, clusterName); err != nil { + return err + } + + if len(files) > 0 { + for _, file := range files { + filesToDecrypt[file] = "" + } + } else { + // if only cluster name is passed, encrypt all files + klog.Infof("decrypting all files for cluster %s", clusterName) + filesToDecrypt["*"] = "" + } + + _, err := backendProvider.DecryptFiles(ctx, clusterName, filesToDecrypt) + return err +} diff --git a/cmd/delete.go b/cmd/delete.go index 2f2bcc2..8e060c1 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -39,7 +39,7 @@ func init() { func runDeleteCluster(cmd *cobra.Command, clusterName string) error { ctx := cmd.Context() - backendProvider := backend.NewProvider(clusterOpts.backend) + backendProvider := backend.NewProvider(clusterOpts.backend, clusterOpts.encryptState) if backendProvider == nil { return errors.New("backend provider not specified, options are: " + strings.Join(backend.ListProviders(), ",")) } @@ -57,11 +57,11 @@ func runDeleteCluster(cmd *cobra.Command, clusterName string) error { return err } - if err := clusterState.Infrastructure.PreCmd(ctx, clusterState.Values); err != nil { + if err = clusterState.Infrastructure.PreCmd(ctx, clusterState.Values); err != nil { return err } - if err := clusterState.Infrastructure.Delete(ctx, clusterState.Values, cmd.Flags().Changed("force")); err != nil { + if err = clusterState.Infrastructure.Delete(ctx, clusterState.Values, cmd.Flags().Changed("force")); err != nil { return err } diff --git a/cmd/encrypt.go b/cmd/encrypt.go new file mode 100644 index 0000000..40f5e50 --- /dev/null +++ b/cmd/encrypt.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + klog "k8s.io/klog/v2" + + "capi-bootstrap/providers/backend" +) + +var encryptCmd = &cobra.Command{ + Use: "encrypt", + Short: "", + Long: ``, + Args: func(_ *cobra.Command, args []string) error { + if len(args) < 1 { + return fmt.Errorf("please specify a cluster name") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runEncryptFiles(cmd, args[0], args[0:]) + }, +} + +func init() { + // flags for the backend provider + encryptCmd.Flags().StringVar(&clusterOpts.backend, "backend", "file", + "The backend provider to use to store configuration for the cluster") + + rootCmd.AddCommand(encryptCmd) +} + +func runEncryptFiles(cmd *cobra.Command, clusterName string, files []string) error { + ctx := cmd.Context() + filesToEncrypt := make(map[string]string, len(files)) + + backendProvider := backend.NewProvider(clusterOpts.backend, clusterOpts.encryptState) + if backendProvider == nil { + return fmt.Errorf("backend provider not specified, options are: %s", strings.Join(backend.ListProviders(), ",")) + } + if err := backendProvider.PreCmd(ctx, clusterName); err != nil { + return err + } + + for _, file := range files { + //nolint:gosec + f, err := os.ReadFile(file) + if err != nil { + return err + } + filesToEncrypt[file] = string(f) + } + + if len(files) == 0 { + // if only cluster name is passed, encrypt all files + klog.Infof("encrypting all files for cluster %s", clusterName) + filesToEncrypt = map[string]string{ + "*": "", + } + } + + _, err := backendProvider.EncryptFiles(ctx, clusterName, filesToEncrypt) + return err +} diff --git a/cmd/get.go b/cmd/get.go index 84e08ba..092f00c 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -2,7 +2,7 @@ package cmd import ( "github.com/spf13/cobra" - "k8s.io/klog/v2" + klog "k8s.io/klog/v2" ) var getCmd = &cobra.Command{ diff --git a/cmd/get_kubeconfig.go b/cmd/get_kubeconfig.go index 9f7bb50..5286bd6 100644 --- a/cmd/get_kubeconfig.go +++ b/cmd/get_kubeconfig.go @@ -38,7 +38,7 @@ func runGetKubeconfig(cmd *cobra.Command, clusterName string) error { if err != nil { return err } - backendProvider := backend.NewProvider(backendName) + backendProvider := backend.NewProvider(backendName, clusterOpts.encryptState) if backendProvider == nil { return errors.New("backend provider not specified, options are: " + strings.Join(backend.ListProviders(), ",")) } diff --git a/cmd/help.go b/cmd/help.go index a7504e2..a687ceb 100644 --- a/cmd/help.go +++ b/cmd/help.go @@ -2,7 +2,7 @@ package cmd import ( "github.com/spf13/cobra" - "k8s.io/klog/v2" + klog "k8s.io/klog/v2" ) var helpCmd = &cobra.Command{ diff --git a/cmd/kubectl.go b/cmd/kubectl.go index 5fceba5..0effb58 100644 --- a/cmd/kubectl.go +++ b/cmd/kubectl.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "log" "math/rand" "os" "strings" @@ -29,7 +30,10 @@ func init() { if kubeconfig == "" { kubeconfig = DefaultKubeconfig if _, err := os.Stat(kubeconfig); err == nil { - os.Setenv("KUBECONFIG", kubeconfig) + err = os.Setenv("KUBECONFIG", kubeconfig) + if err != nil { + log.Fatalf("failed to set KUBECONFIG environment variable: %v", err) + } } } diff --git a/cmd/list.go b/cmd/list.go index 8efbc2e..8e614fa 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -28,7 +28,7 @@ func init() { func runListCluster(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() - backendProvider := backend.NewProvider(clusterOpts.backend) + backendProvider := backend.NewProvider(clusterOpts.backend, clusterOpts.encryptState) if backendProvider == nil { return errors.New("backend provider not specified, options are: " + strings.Join(backend.ListProviders(), ",")) } diff --git a/cmd/put.go b/cmd/put.go index 9779d4c..9d5cf05 100644 --- a/cmd/put.go +++ b/cmd/put.go @@ -2,7 +2,7 @@ package cmd import ( "github.com/spf13/cobra" - "k8s.io/klog/v2" + klog "k8s.io/klog/v2" ) var putCmd = &cobra.Command{ diff --git a/cmd/root.go b/cmd/root.go index 491c031..3257e5c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" - "k8s.io/klog/v2" + klog "k8s.io/klog/v2" "sigs.k8s.io/yaml" "capi-bootstrap/types" @@ -58,7 +58,7 @@ func init() { } func loadConfig(cmd *cobra.Command, args []string) error { - configFile = strings.Replace(configFile, "$XDG_CONFIG_HOME", os.Getenv("XDG_CONFIG_HOME"), -1) + configFile = strings.ReplaceAll(configFile, "$XDG_CONFIG_HOME", os.Getenv("XDG_CONFIG_HOME")) klog.V(5).Infof("looking for config file: %s", configFile) _, err := os.Stat(configFile) @@ -71,6 +71,7 @@ func loadConfig(cmd *cobra.Command, args []string) error { } // assume there is a config file + //nolint:gosec c, err := os.ReadFile(configFile) if err != nil { return err diff --git a/cmd/version.go b/cmd/version.go index fb75883..18f4ba1 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -2,7 +2,7 @@ package cmd import ( "github.com/spf13/cobra" - "k8s.io/klog/v2" + klog "k8s.io/klog/v2" ) var ( diff --git a/go.mod b/go.mod index 34234d8..4df4215 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,9 @@ module capi-bootstrap -go 1.23.0 +go 1.25.0 require ( + filippo.io/age v1.3.1 github.com/aws/aws-sdk-go-v2 v1.30.4 github.com/aws/aws-sdk-go-v2/credentials v1.17.28 github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0 @@ -12,12 +13,13 @@ require ( github.com/k3s-io/cluster-api-k3s v0.1.10-0.20240507063454-ae3b2166b1b9 github.com/linode/cluster-api-provider-linode v0.6.0 github.com/linode/linodego v1.39.0 + github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.9.0 go.uber.org/mock v0.4.0 - golang.org/x/oauth2 v0.22.0 - gopkg.in/yaml.v3 v3.0.1 + golang.org/x/crypto v0.48.0 + golang.org/x/oauth2 v0.27.0 k8s.io/api v0.31.0 k8s.io/apimachinery v0.31.0 k8s.io/client-go v0.31.0 @@ -30,6 +32,7 @@ require ( ) require ( + filippo.io/hpke v0.4.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/semver/v3 v3.2.1 // indirect @@ -79,8 +82,8 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.0.1 // indirect github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/websocket v1.5.3 // indirect @@ -115,6 +118,7 @@ require ( github.com/prometheus/procfs v0.15.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect + github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -148,11 +152,11 @@ require ( go.uber.org/zap v1.27.0 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/net v0.28.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.23.0 // indirect - golang.org/x/term v0.23.0 // indirect - golang.org/x/text v0.17.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.5.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect @@ -163,6 +167,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.31.0 // indirect k8s.io/apiserver v0.31.0 // indirect k8s.io/cli-runtime v0.31.0 // indirect diff --git a/go.sum b/go.sum index b3940f9..da93823 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,10 @@ +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -168,14 +174,14 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v63 v63.0.0 h1:13xwK/wk9alSokujB9lJkuzdmQuVn2QCPeck76wR3nE= github.com/google/go-github/v63 v63.0.0/go.mod h1:IqbcrgUmIcEaioWrGYei/09o+ge5vhffGOcxrO0AfmA= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -286,8 +292,8 @@ github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65 github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= @@ -295,6 +301,10 @@ github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed h1:KT7hI8vYXgU0s2qaMkrfq9tCA1w/iEPgfredVP+4Tzw= +github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= +github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf h1:o1uxfymjZ7jZ4MsgCErcwWGtVKSiNAXtS59Lhs6uI/g= +github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= @@ -406,8 +416,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= @@ -433,11 +443,11 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= -golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -445,8 +455,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -463,8 +473,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= -golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -472,8 +482,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= -golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -482,8 +492,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -496,8 +506,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= -golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/providers/backend/backend.go b/providers/backend/backend.go index 7ef58ae..8ea4d63 100644 --- a/providers/backend/backend.go +++ b/providers/backend/backend.go @@ -5,12 +5,12 @@ import ( "capi-bootstrap/providers/backend/s3" ) -func NewProvider(name string) Provider { +func NewProvider(name string, encryptState bool) Provider { switch name { case "s3": - return s3.NewBackend() + return s3.NewBackend(encryptState) case "github": - return github.NewBackend() + return github.NewBackend(encryptState) default: return nil } diff --git a/providers/backend/backend_test.go b/providers/backend/backend_test.go index 9b71308..f91284c 100644 --- a/providers/backend/backend_test.go +++ b/providers/backend/backend_test.go @@ -17,13 +17,13 @@ func TestNewProvider(t *testing.T) { } tests := []test{ {name: "file", input: "file", want: nil}, - {name: "s3", input: "s3", want: s3.NewBackend()}, - {name: "github", input: "github", want: github.NewBackend()}, + {name: "s3", input: "s3", want: s3.NewBackend(false)}, + {name: "github", input: "github", want: github.NewBackend(false)}, {name: "not matching name", input: "wrong", want: nil}, {name: "no name", input: "", want: nil}, } for _, tc := range tests { - actual := NewProvider(tc.input) + actual := NewProvider(tc.input, false) assert.Equal(t, tc.want, actual) } } diff --git a/providers/backend/github/github.go b/providers/backend/github/github.go index 1be7783..e57d9ef 100644 --- a/providers/backend/github/github.go +++ b/providers/backend/github/github.go @@ -1,7 +1,10 @@ package github import ( + "bytes" "context" + "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -12,24 +15,31 @@ import ( "strings" "time" + "filippo.io/age" "github.com/google/go-github/v63/github" + "github.com/shurcooL/githubv4" + "golang.org/x/crypto/nacl/box" + "golang.org/x/oauth2" v1 "k8s.io/client-go/tools/clientcmd/api/v1" - "k8s.io/klog/v2" + klog "k8s.io/klog/v2" k8syaml "sigs.k8s.io/yaml" + "capi-bootstrap/utils" capiYaml "capi-bootstrap/yaml" ) const defaultBranchName = "main" -func NewBackend() *Backend { +func NewBackend(encryptState bool) *Backend { return &Backend{ - Name: "github", - Repo: os.Getenv("GITHUB_REPO"), - Org: os.Getenv("GITHUB_ORG"), - Token: os.Getenv("GITHUB_TOKEN"), - clusters: make(map[string]*v1.Config), - branchName: os.Getenv("GITHUB_BRANCH"), + Name: "github", + Repo: os.Getenv("GITHUB_REPO"), + Org: os.Getenv("GITHUB_ORG"), + Token: os.Getenv("GITHUB_TOKEN"), + clusters: make(map[string]*v1.Config), + branchName: os.Getenv("GITHUB_BRANCH"), + Encrypted: encryptState, + BatchWrites: os.Getenv("GITHUB_BATCH_WRITES") != "", } } @@ -39,16 +49,20 @@ type CreateBranchOptions struct { } type Backend struct { - Name string - Org string - Repo string - Token string - - client *github.Client - user *github.User - branch *github.Branch - branchName string - clusters map[string]*v1.Config + Name string + Org string + Repo string + Token string `json:"-"` + Encrypted bool + BatchWrites bool + + crypto *utils.AgeBackend + restClient *github.Client + graphqlClient *githubv4.Client + user *github.User + branch *github.Branch + branchName string + clusters map[string]*v1.Config } func (b *Backend) PreCmd(ctx context.Context, clusterName string) error { @@ -76,31 +90,62 @@ func (b *Backend) PreCmd(ctx context.Context, clusterName string) error { if err != nil { return fmt.Errorf("[github backend] failed to authenticate to repo %s/%s: %v", b.Org, b.Repo, err) } - b.client = client + b.restClient = client b.user = user - branch, httpResp, err := b.client.Repositories.GetBranch(context.Background(), b.Org, b.Repo, b.branchName, 2) - if err != nil && httpResp.StatusCode != http.StatusNotFound { - return fmt.Errorf("[github backend] unexpected error when checking for branch %s: %v", b.branchName, err) - } else { + branch, httpResp, err := b.restClient.Repositories.GetBranch(ctx, b.Org, b.Repo, b.branchName, 2) + if err != nil { + if httpResp != nil { + if httpResp.StatusCode != http.StatusNotFound { + return fmt.Errorf("[github backend] unexpected error when checking for branch %s: %v", b.branchName, err) + } + } else { + return fmt.Errorf("[github backend] failed to check for branch %s: %v", b.branchName, err) + } b.branch = branch } + src := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: b.Token}, + ) + b.graphqlClient = githubv4.NewClient(oauth2.NewClient(ctx, src)) + klog.Infof("[github backend] successfully authenticated to state repo %s/%s using branch %s", b.Org, b.Repo, b.branchName) return nil } func (b *Backend) Read(ctx context.Context, clusterName string) (*v1.Config, error) { - client := b.client + client := b.restClient klog.V(4).Infof("[github backend] trying to read state file %s/%s from branch %s in repo %s/%s", clusterName, "kubeconfig.yaml", b.branchName, b.Org, b.Org) + // TODO (rk) add a marker file in the clusterName branch to indicate that the cluster state is encrypted with age if b.branch != nil { - file, _, _, err := client.Repositories.GetContents(ctx, b.Org, b.Repo, path.Join("clusters", clusterName, "kubeconfig.yaml"), &github.RepositoryContentGetOptions{ + kubeconfigPath := path.Join("clusters", clusterName, "kubeconfig.yaml") + if b.Encrypted { + kubeconfigPath = path.Join("clusters", clusterName, "kubeconfig.yaml.enc") + } + file, _, httpResp, err := client.Repositories.GetContents(ctx, b.Org, b.Repo, kubeconfigPath, &github.RepositoryContentGetOptions{ Ref: b.branchName, }) if err != nil { - return nil, err + if httpResp != nil { + if httpResp.StatusCode == http.StatusNotFound { + // try to fetch the encrypted kubeconfig + file, _, _, err = client.Repositories.GetContents(ctx, b.Org, b.Repo, kubeconfigPath+".enc", &github.RepositoryContentGetOptions{ + Ref: b.branchName, + }) + if err != nil { + return nil, fmt.Errorf("[github backend] failed to read cluster %s kubeconfig.yaml or kubeconfig.yaml.enc file: %v", clusterName, err) + } + b.Encrypted = true + } else { + // TODO (rk) support deletes when kubeconfig doesn't yet exist + return nil, err + } + } else { + return nil, err + } } rawStateFile, err := file.GetContent() @@ -108,6 +153,15 @@ func (b *Backend) Read(ctx context.Context, clusterName string) (*v1.Config, err return nil, err } + if b.Encrypted { + decryptedFiles, err := b.DecryptFiles(ctx, clusterName, map[string]string{ + clusterName: rawStateFile, + }) + if err != nil { + return nil, err + } + rawStateFile = decryptedFiles[clusterName] + } kubeconfig := strings.NewReader(rawStateFile) rawKubeconfig, err := io.ReadAll(kubeconfig) @@ -134,12 +188,12 @@ func (b *Backend) Read(ctx context.Context, clusterName string) (*v1.Config, err func (b *Backend) Delete(ctx context.Context, clusterName string) error { klog.V(4).Infof("[github backend] trying to delete state files in remote repo: %s", clusterName) - branch, _, err := b.client.Repositories.GetBranch(ctx, b.Org, b.Repo, b.branchName, 2) + branch, _, err := b.restClient.Repositories.GetBranch(ctx, b.Org, b.Repo, b.branchName, 2) if err != nil { return err } b.branch = branch - tree, _, err := b.client.Git.GetTree(ctx, b.Org, b.Repo, b.branch.Commit.GetSHA(), true) + tree, _, err := b.restClient.Git.GetTree(ctx, b.Org, b.Repo, b.branch.Commit.GetSHA(), true) if err != nil { return err } @@ -153,7 +207,7 @@ func (b *Backend) Delete(ctx context.Context, clusterName string) error { } } - nt, _, err := b.client.Git.CreateTree(ctx, b.Org, b.Repo, b.branch.Commit.GetSHA(), tree.Entries) + nt, _, err := b.restClient.Git.CreateTree(ctx, b.Org, b.Repo, b.branch.Commit.GetSHA(), tree.Entries) if err != nil { return err } @@ -178,7 +232,7 @@ func (b *Backend) Delete(ctx context.Context, clusterName string) error { // Verification: nil, // TODO sign commits } - newCommit, _, err := b.client.Git.CreateCommit(ctx, b.Org, b.Repo, commit, &github.CreateCommitOptions{}) + newCommit, _, err := b.restClient.Git.CreateCommit(ctx, b.Org, b.Repo, commit, &github.CreateCommitOptions{}) if err != nil { return err } @@ -191,7 +245,7 @@ func (b *Backend) Delete(ctx context.Context, clusterName string) error { }, } - if _, _, err = b.client.Git.UpdateRef(ctx, b.Org, b.Repo, ref, true); err != nil { + if _, _, err = b.restClient.Git.UpdateRef(ctx, b.Org, b.Repo, ref, true); err != nil { return err } @@ -201,7 +255,7 @@ func (b *Backend) Delete(ctx context.Context, clusterName string) error { } func (b *Backend) ListClusters(ctx context.Context) (map[string]*v1.Config, error) { - _, clusterConfigs, _, err := b.client.Repositories.GetContents(ctx, b.Org, b.Repo, "clusters", &github.RepositoryContentGetOptions{ + _, clusterConfigs, _, err := b.restClient.Repositories.GetContents(ctx, b.Org, b.Repo, "clusters", &github.RepositoryContentGetOptions{ Ref: b.branchName, }) if err != nil { @@ -213,7 +267,7 @@ func (b *Backend) ListClusters(ctx context.Context) (map[string]*v1.Config, erro klog.Warningf("expected remote content to be a directory, but was a %s instead", cluster.GetType()) continue } - rawKC, _, _, err := b.client.Repositories.GetContents(ctx, b.Org, b.Repo, path.Join("clusters", *cluster.Name, "kubeconfig.yaml"), &github.RepositoryContentGetOptions{ + rawKC, _, _, err := b.restClient.Repositories.GetContents(ctx, b.Org, b.Repo, path.Join("clusters", *cluster.Name, "kubeconfig.yaml"), &github.RepositoryContentGetOptions{ Ref: b.branchName, }) if err != nil { @@ -249,22 +303,64 @@ func (b *Backend) WriteConfig(ctx context.Context, clusterName string, config *v if err != nil { return err } + var rawKubeConfig string y, err := k8syaml.JSONToYAML(js) if err != nil { return err } - filePath := path.Join("clusters", clusterName, "kubeconfig.yaml") - _, err = b.uploadFile(ctx, string(y), filePath, clusterName) + rawKubeConfig = string(y) + kubeconfigPath := path.Join("clusters", clusterName, "kubeconfig.yaml") + if b.Encrypted { + kubeconfigPath = path.Join("clusters", clusterName, "kubeconfig.yaml.enc") + } + if b.Encrypted { + encryptedFiles, err := b.EncryptFiles(ctx, clusterName, map[string]string{kubeconfigPath: string(y)}) + if err != nil { + return err + } + rawKubeConfig = encryptedFiles[kubeconfigPath] + klog.Infof("[github backend] encrypted kubeconfig for cluster %s", clusterName) + } + + _, err = b.uploadFile(ctx, rawKubeConfig, kubeconfigPath, clusterName) if err != nil { - return fmt.Errorf("failed to write cluster %s config: %v", clusterName, err) + return fmt.Errorf("failed to write cluster %s kubeconfig: %v", clusterName, err) } return nil } func (b *Backend) WriteFiles(ctx context.Context, clusterName string, cloudInitConfig *capiYaml.Config) ([]string, error) { - downloadCmds := make([]string, len(cloudInitConfig.WriteFiles)) - newFiles := make([]capiYaml.InitFile, len(cloudInitConfig.WriteFiles)) + downloadCmds := make([]string, 0, len(cloudInitConfig.WriteFiles)) + newFiles := make([]capiYaml.InitFile, 0, len(cloudInitConfig.WriteFiles)) + + if b.BatchWrites { + _, err := b.batchFiles(ctx, clusterName, cloudInitConfig.WriteFiles) + if err != nil { + return nil, err + } + tarballPath := path.Join("clusters", clusterName, fmt.Sprintf("%s.tgz", clusterName)) + + tarballFile, _, _, err := b.restClient.Repositories.GetContents(ctx, b.Org, b.Repo, tarballPath, &github.RepositoryContentGetOptions{ + Ref: b.branchName, + }) + if err != nil { + return nil, err + } + // TODO (rk) support encrypting the tarball instead of the individual files + + remoteTarballPath := fmt.Sprintf("/root/%s.tgz", clusterName) + tarballDownloadCmd := fmt.Sprintf("curl -sL -H 'Accept: application/vnd.github.raw+json' -H 'Authorization: Bearer %s' -H 'X-GitHub-Api-Version: 2022-11-28' '%s' -o '%s'", b.Token, tarballFile.GetDownloadURL(), remoteTarballPath) + extractTarballCmd := fmt.Sprintf("tar -C / -xzvf %s", remoteTarballPath) + downloadCmds = append(downloadCmds, tarballDownloadCmd, extractTarballCmd) + extractCmd := fmt.Sprintf("tar -xf %s --to-command='xargs -0 cloud-init query -f > /$TAR_FILENAME'", remoteTarballPath) + if b.Encrypted { + extractCmd = fmt.Sprintf("tar -xf %s --to-command='/usr/local/bin/age --decrypt -i /root/cluster.age /$TAR_FILENAME | xargs -0 cloud-init query -f > /${TAR_FILENAME%%.enc}'", remoteTarballPath) + } + downloadCmds = append(downloadCmds, extractCmd) + return downloadCmds, nil + } + for i, file := range cloudInitConfig.WriteFiles { newCmd, newFile, err := b.writeFile(ctx, clusterName, file) if err != nil { @@ -283,6 +379,10 @@ func (b *Backend) writeFile(ctx context.Context, clusterName string, cloudInitFi } remotePath := path.Join("clusters", clusterName, "files", cloudInitFile.Path) + if b.Encrypted { + remotePath = path.Join("clusters", clusterName, "files", cloudInitFile.Path+".enc") + } + downloadURL, err := b.uploadFile(ctx, cloudInitFile.Content, remotePath, clusterName) if err != nil { return "", nil, fmt.Errorf("couldn't upload object: %v", err) @@ -291,32 +391,101 @@ func (b *Backend) writeFile(ctx context.Context, clusterName string, cloudInitFi cloudInitFile.Content = "" klog.V(4).Infof("[github backend] updated existing state file %s for cluster %s in remote repo %s/%s", remotePath, clusterName, b.Org, b.Repo) - downloadCmd := fmt.Sprintf("curl -sL -H 'Accept: application/vnd.github.raw+json' -H 'Authorization: Bearer %s' -H 'X-GitHub-Api-Version: 2022-11-28' '%s' | xargs -0 cloud-init query -f > %s", b.Token, downloadURL, cloudInitFile.Path) + downloadCmd := fmt.Sprintf("curl -sL -H 'Accept: application/vnd.github.raw+json' -H 'Authorization: Bearer %s' -H 'X-GitHub-Api-Version: 2022-11-28' '%s' | /usr/local/bin/age --decrypt -i /root/cluster.age | xargs -0 cloud-init query -f > %s", b.Token, downloadURL, cloudInitFile.Path) + if cloudInitFile.Path == "/root/cluster.age" { + downloadCmd = fmt.Sprintf("curl -sL -H 'Accept: application/vnd.github.raw+json' -H 'Authorization: Bearer %s' -H 'X-GitHub-Api-Version: 2022-11-28' '%s' | xargs -0 cloud-init query -f > %s", b.Token, downloadURL, cloudInitFile.Path) + } return downloadCmd, &cloudInitFile, nil } +func (b *Backend) batchFiles(ctx context.Context, clusterName string, initFiles []capiYaml.InitFile) (string, error) { + var m struct { + CreateCommitOnBranch struct { + Commit struct { + URL githubv4.URI + } + } `graphql:"createCommitOnBranch(input: $input)"` + } + + files, err := capiYaml.TarFromInitFiles(initFiles, true) + if err != nil { + return "", err + } + + tarballPath := path.Join("clusters", clusterName, fmt.Sprintf("%s.tgz", clusterName)) + + var encodedFiles []githubv4.FileAddition + encodedFiles = append(encodedFiles, githubv4.FileAddition{ + Path: githubv4.String(tarballPath), + Contents: githubv4.Base64String(base64.StdEncoding.EncodeToString(files)), + }) + + for _, file := range initFiles { + if b.Encrypted { + file.Path += ".enc" + } + encodedFiles = append(encodedFiles, githubv4.FileAddition{ + Path: githubv4.String(path.Join("clusters", clusterName, file.Path)), + Contents: githubv4.Base64String(base64.StdEncoding.EncodeToString([]byte(file.Content))), + }) + } + + input := githubv4.CreateCommitOnBranchInput{ + Branch: githubv4.CommittableBranch{ + RepositoryNameWithOwner: githubv4.NewString(githubv4.String(fmt.Sprintf("%s/%s", b.Org, b.Repo))), + BranchName: githubv4.NewString(githubv4.String(*b.branch.Name)), + }, + Message: githubv4.CommitMessage{ + Headline: githubv4.String(fmt.Sprintf("capi-bootstrap cluster %s - update %d files", clusterName, len(encodedFiles))), + }, + FileChanges: &githubv4.FileChanges{ + Additions: &encodedFiles, + Deletions: nil, + }, + ExpectedHeadOid: githubv4.GitObjectID(b.branch.GetCommit().GetSHA()), + } + + klog.Infof("createCommitOnBranch: create/update %d files\n", len(encodedFiles)) + // TODO (rk) sign commits + err = b.graphqlClient.Mutate(ctx, &m, input, nil) + if err != nil { + klog.Errorf("failed to create commit: %v\n", err) + return "", err + } + + klog.Infof("Successfully created commit: %s\n", m.CreateCommitOnBranch.Commit.URL) + return path.Base(m.CreateCommitOnBranch.Commit.URL.String()), nil +} + func (b *Backend) uploadFile(ctx context.Context, fileContent string, remotePath string, clusterName string) (string, error) { // need config for the SHA - config, _, httpResp, err := b.client.Repositories.GetContents(ctx, b.Org, b.Repo, remotePath, &github.RepositoryContentGetOptions{ + remoteFileContent, _, httpResp, err := b.restClient.Repositories.GetContents(ctx, b.Org, b.Repo, remotePath, &github.RepositoryContentGetOptions{ Ref: b.branchName, }) if err != nil { - switch httpResp.StatusCode { - case http.StatusNotFound, http.StatusOK, http.StatusFound, http.StatusNotModified: - // expected - case http.StatusForbidden: - return "", fmt.Errorf("failed to upload state file %s due to permissions error: %w", remotePath, err) - default: + if httpResp != nil { + switch httpResp.StatusCode { + case http.StatusNotFound, http.StatusOK, http.StatusFound, http.StatusNotModified: + // expected + case http.StatusForbidden: + return "", fmt.Errorf("failed to upload state file %s due to permissions error: %w", remotePath, err) + default: + return "", err + } + } else { return "", err } } - var contentResp *github.RepositoryContentResponse + var ( + contentResp *github.RepositoryContentResponse + downloadURL string + ) switch httpResp.StatusCode { // https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#get-repository-content--status-codes case http.StatusNotFound: // create - contentResp, _, err = b.client.Repositories.CreateFile(ctx, b.Org, b.Repo, remotePath, &github.RepositoryContentFileOptions{ + contentResp, _, err = b.restClient.Repositories.CreateFile(ctx, b.Org, b.Repo, remotePath, &github.RepositoryContentFileOptions{ Content: []byte(fileContent), Branch: PointerTo(b.branchName), Committer: &github.CommitAuthor{ @@ -332,8 +501,11 @@ func (b *Backend) uploadFile(ctx context.Context, fileContent string, remotePath } default: + if remoteFileContent == nil { + return "", fmt.Errorf("failed to update file %s due to empty remote file content response", remotePath) + } // update - contentResp, _, err = b.client.Repositories.UpdateFile(ctx, b.Org, b.Repo, remotePath, &github.RepositoryContentFileOptions{ + contentResp, _, err = b.restClient.Repositories.UpdateFile(ctx, b.Org, b.Repo, remotePath, &github.RepositoryContentFileOptions{ Content: []byte(fileContent), Branch: PointerTo(b.branchName), Committer: &github.CommitAuthor{ @@ -342,14 +514,163 @@ func (b *Backend) uploadFile(ctx context.Context, fileContent string, remotePath Email: b.user.Email, Login: b.user.Login, }, - SHA: config.SHA, + SHA: remoteFileContent.SHA, Message: PointerTo(fmt.Sprintf("updating cluster %s state file", clusterName)), }) if err != nil { return "", err } } - return contentResp.Content.GetDownloadURL(), nil + downloadURL = contentResp.Content.GetDownloadURL() + + if downloadURL == "" { + repoContent, _, _, err := b.restClient.Repositories.GetContents(ctx, b.Org, b.Repo, remotePath, &github.RepositoryContentGetOptions{ + Ref: b.branchName, + }) + if err != nil { + return "", err + } + downloadURL = repoContent.GetDownloadURL() + } + + return downloadURL, nil +} + +func (b *Backend) EncryptFiles(ctx context.Context, clusterName string, files map[string]string) (map[string]string, error) { + var ( + recipients []age.Recipient + encryptedFiles = make(map[string]string, len(files)) + ) + ageBackend, err := b.GetCryptoBackend(ctx, clusterName) + if err != nil { + return nil, err + } + b.crypto = ageBackend + recipients = append(b.crypto.AdditionalRecipients, b.crypto.ClusterIdentity.Recipient(), b.crypto.LocalIdentity.Recipient()) + + for file, content := range files { + r, err := age.EncryptReader(strings.NewReader(content), recipients...) + if err != nil { + return nil, err + } + buf := &bytes.Buffer{} + if _, err := io.Copy(buf, r); err != nil { + return nil, err + } + encryptedFiles[file] = buf.String() + } + return encryptedFiles, nil +} + +func (b *Backend) DecryptFiles(ctx context.Context, clusterName string, files map[string]string) (map[string]string, error) { + var decryptedFiles = make(map[string]string, len(files)) + ageBackend, err := b.GetCryptoBackend(ctx, clusterName) + if err != nil { + return nil, err + } + b.crypto = ageBackend + + if files == nil { + return nil, fmt.Errorf("no files to decrypt") + } + + for file, content := range files { + r, err := age.Decrypt(strings.NewReader(content), b.crypto.LocalIdentity) + if err != nil { + return nil, err + } + buf := &bytes.Buffer{} + if _, err := io.Copy(buf, r); err != nil { + return nil, err + } + decryptedFiles[file] = buf.String() + } + return decryptedFiles, nil +} + +func (b *Backend) GetCryptoBackend(_ context.Context, clusterName string) (*utils.AgeBackend, error) { + return utils.NewAgeBackend("", clusterName) +} + +func (b *Backend) GetClusterAgeKeyInitFile() *capiYaml.InitFile { + if !b.Encrypted { + return nil + } + return &capiYaml.InitFile{ + Path: "/root/cluster.age", + Content: b.crypto.ClusterIdentity.String(), + Owner: "root", + Permissions: "0400", + } +} + +func (b *Backend) UploadClusterAgeKey(ctx context.Context, clusterName string) (*capiYaml.InitFile, error) { + repoPublicKey, httpResp, err := b.restClient.Actions.GetRepoPublicKey(ctx, b.Org, b.Repo) + if err != nil { + switch httpResp.StatusCode { + case http.StatusNotFound: + return nil, fmt.Errorf("repo %s is missing public key: %w", b.Repo, err) + case http.StatusOK, http.StatusFound, http.StatusNotModified: + // expected + case http.StatusForbidden: + return nil, fmt.Errorf("failed to get repo %s public key due to permissions error: %w", b.Repo, err) + default: + return nil, err + } + return nil, err + } + + publicKeyBytes, err := base64.StdEncoding.DecodeString(*repoPublicKey.Key) + if err != nil { + return nil, err + } + var publicKeyDecoded [32]byte + copy(publicKeyDecoded[:], publicKeyBytes) + + // Encrypt the secret value + encrypted, err := box.SealAnonymous(nil, []byte(b.crypto.ClusterIdentity.String()), (*[32]byte)(publicKeyBytes), rand.Reader) + if err != nil { + return nil, err + } + // Encode the encrypted value in base64 + encryptedBase64 := base64.StdEncoding.EncodeToString(encrypted) + + secretName := fmt.Sprintf("capi_bootstrap_%s_key", clusterName) + newSecret := &github.EncryptedSecret{ + Name: secretName, + KeyID: repoPublicKey.GetKeyID(), + EncryptedValue: encryptedBase64, + Visibility: "private", + } + httpResp, err = b.restClient.Actions.CreateOrUpdateRepoSecret(ctx, b.Org, b.Repo, newSecret) + if err != nil { + switch httpResp.StatusCode { + case http.StatusNotFound: + return nil, fmt.Errorf("failed to create/update repo %s secret for cluster %s age key: %w", b.Repo, clusterName, err) + case http.StatusOK, http.StatusFound, http.StatusNotModified: + // expected + case http.StatusForbidden: + return nil, fmt.Errorf("failed to create/update repo %s secret for cluster %s age key due to permissions error: %w", b.Repo, clusterName, err) + default: + return nil, err + } + return nil, err + } + klog.Infof("created secret %s in github repo %s", secretName, b.Repo) + return &capiYaml.InitFile{ + Path: "/root/cluster.age", + Content: b.crypto.ClusterIdentity.String(), + Owner: "root", + Permissions: "0400", + }, nil +} + +func (b *Backend) PrintClusterAgeKey(_ context.Context, clusterName string) error { + // TODO (rk) add envvar to suppress this output + klog.Infof("For security purposes, the cluster encryption key will only be displayed once, after which it can’t be recovered. Be sure to keep it in a safe place.\n") + klog.Infof("The cluster encryption key is generated using age - https://github.com/FiloSottile/age") + klog.Infof("This is the age key for cluster %s.\n\n%s\n\n", clusterName, b.crypto.ClusterIdentity.String()) + return nil } func authenticate(ctx context.Context, token, org, repo string) (*github.Client, *github.User, error) { diff --git a/providers/backend/mock/mock_types.go b/providers/backend/mock/mock_types.go index f5980d6..23b5649 100644 --- a/providers/backend/mock/mock_types.go +++ b/providers/backend/mock/mock_types.go @@ -10,6 +10,7 @@ package mock_backend import ( + utils "capi-bootstrap/utils" yaml "capi-bootstrap/yaml" context "context" reflect "reflect" @@ -41,6 +42,21 @@ func (m *MockProvider) EXPECT() *MockProviderMockRecorder { return m.recorder } +// DecryptFiles mocks base method. +func (m *MockProvider) DecryptFiles(ctx context.Context, clusterName string, files map[string]string) (map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DecryptFiles", ctx, clusterName, files) + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DecryptFiles indicates an expected call of DecryptFiles. +func (mr *MockProviderMockRecorder) DecryptFiles(ctx, clusterName, files any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DecryptFiles", reflect.TypeOf((*MockProvider)(nil).DecryptFiles), ctx, clusterName, files) +} + // Delete mocks base method. func (m *MockProvider) Delete(ctx context.Context, clusterName string) error { m.ctrl.T.Helper() @@ -55,19 +71,63 @@ func (mr *MockProviderMockRecorder) Delete(ctx, clusterName any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockProvider)(nil).Delete), ctx, clusterName) } +// EncryptFiles mocks base method. +func (m *MockProvider) EncryptFiles(ctx context.Context, clusterName string, files map[string]string) (map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EncryptFiles", ctx, clusterName, files) + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EncryptFiles indicates an expected call of EncryptFiles. +func (mr *MockProviderMockRecorder) EncryptFiles(ctx, clusterName, files any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EncryptFiles", reflect.TypeOf((*MockProvider)(nil).EncryptFiles), ctx, clusterName, files) +} + +// GetClusterAgeKeyInitFile mocks base method. +func (m *MockProvider) GetClusterAgeKeyInitFile() *yaml.InitFile { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClusterAgeKeyInitFile") + ret0, _ := ret[0].(*yaml.InitFile) + return ret0 +} + +// GetClusterAgeKeyInitFile indicates an expected call of GetClusterAgeKeyInitFile. +func (mr *MockProviderMockRecorder) GetClusterAgeKeyInitFile() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterAgeKeyInitFile", reflect.TypeOf((*MockProvider)(nil).GetClusterAgeKeyInitFile)) +} + +// GetCryptoBackend mocks base method. +func (m *MockProvider) GetCryptoBackend(ctx context.Context, clusterName string) (*utils.AgeBackend, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCryptoBackend", ctx, clusterName) + ret0, _ := ret[0].(*utils.AgeBackend) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCryptoBackend indicates an expected call of GetCryptoBackend. +func (mr *MockProviderMockRecorder) GetCryptoBackend(ctx, clusterName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCryptoBackend", reflect.TypeOf((*MockProvider)(nil).GetCryptoBackend), ctx, clusterName) +} + // ListClusters mocks base method. -func (m *MockProvider) ListClusters(arg0 context.Context) (map[string]*v1.Config, error) { +func (m *MockProvider) ListClusters(ctx context.Context) (map[string]*v1.Config, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListClusters", arg0) + ret := m.ctrl.Call(m, "ListClusters", ctx) ret0, _ := ret[0].(map[string]*v1.Config) ret1, _ := ret[1].(error) return ret0, ret1 } // ListClusters indicates an expected call of ListClusters. -func (mr *MockProviderMockRecorder) ListClusters(arg0 any) *gomock.Call { +func (mr *MockProviderMockRecorder) ListClusters(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListClusters", reflect.TypeOf((*MockProvider)(nil).ListClusters), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListClusters", reflect.TypeOf((*MockProvider)(nil).ListClusters), ctx) } // PreCmd mocks base method. @@ -84,6 +144,20 @@ func (mr *MockProviderMockRecorder) PreCmd(ctx, clusterName any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreCmd", reflect.TypeOf((*MockProvider)(nil).PreCmd), ctx, clusterName) } +// PrintClusterAgeKey mocks base method. +func (m *MockProvider) PrintClusterAgeKey(ctx context.Context, clusterName string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PrintClusterAgeKey", ctx, clusterName) + ret0, _ := ret[0].(error) + return ret0 +} + +// PrintClusterAgeKey indicates an expected call of PrintClusterAgeKey. +func (mr *MockProviderMockRecorder) PrintClusterAgeKey(ctx, clusterName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrintClusterAgeKey", reflect.TypeOf((*MockProvider)(nil).PrintClusterAgeKey), ctx, clusterName) +} + // Read mocks base method. func (m *MockProvider) Read(ctx context.Context, clusterName string) (*v1.Config, error) { m.ctrl.T.Helper() @@ -99,6 +173,21 @@ func (mr *MockProviderMockRecorder) Read(ctx, clusterName any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockProvider)(nil).Read), ctx, clusterName) } +// UploadClusterAgeKey mocks base method. +func (m *MockProvider) UploadClusterAgeKey(ctx context.Context, clusterName string) (*yaml.InitFile, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UploadClusterAgeKey", ctx, clusterName) + ret0, _ := ret[0].(*yaml.InitFile) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UploadClusterAgeKey indicates an expected call of UploadClusterAgeKey. +func (mr *MockProviderMockRecorder) UploadClusterAgeKey(ctx, clusterName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadClusterAgeKey", reflect.TypeOf((*MockProvider)(nil).UploadClusterAgeKey), ctx, clusterName) +} + // WriteConfig mocks base method. func (m *MockProvider) WriteConfig(ctx context.Context, clusterName string, config *v1.Config) error { m.ctrl.T.Helper() diff --git a/providers/backend/s3/s3.go b/providers/backend/s3/s3.go index a84eb85..7d3efdd 100644 --- a/providers/backend/s3/s3.go +++ b/providers/backend/s3/s3.go @@ -16,15 +16,17 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" - "github.com/aws/smithy-go" + smithy "github.com/aws/smithy-go" v1 "k8s.io/client-go/tools/clientcmd/api/v1" + "k8s.io/klog/v2" "k8s.io/utils/ptr" k8syaml "sigs.k8s.io/yaml" + "capi-bootstrap/utils" capiYaml "capi-bootstrap/yaml" ) -func NewBackend() *Backend { +func NewBackend(encryptState bool) *Backend { return &Backend{ Name: "s3", } @@ -47,10 +49,12 @@ type Backend struct { Endpoint string Region string BucketName string - AccessKey string - SecretKey string + AccessKey string `json:"-"` + SecretKey string `json:"-"` Client S3Client `json:"-"` PresignClient PresignClient `json:"-"` + + crypto *utils.AgeBackend } func (b *Backend) PreCmd(_ context.Context, _ string) error { @@ -209,6 +213,31 @@ func (b *Backend) WriteFiles(ctx context.Context, clusterName string, cloudInitC return downloadCmds, nil } +func (b *Backend) EncryptFiles(ctx context.Context, clusterName string, files map[string]string) (map[string]string, error) { + return nil, nil +} + +func (b *Backend) DecryptFiles(ctx context.Context, clusterName string, files map[string]string) (map[string]string, error) { + return nil, nil +} + +func (b *Backend) GetCryptoBackend(_ context.Context, clusterName string) (*utils.AgeBackend, error) { + return utils.NewAgeBackend("", clusterName) +} + +func (b *Backend) GetClusterAgeKeyInitFile() *capiYaml.InitFile { + return nil +} + +func (b *Backend) UploadClusterAgeKey(ctx context.Context, clusterName string) (*capiYaml.InitFile, error) { + return nil, nil +} + +func (b *Backend) PrintClusterAgeKey(_ context.Context, clusterName string) error { + klog.Infof("this is the encryption key for cluster %s and is a sensitive value, copy it now and store it in a safe place.\n\n%s", clusterName, b.crypto.ClusterIdentity.String()) + return nil +} + func (b *Backend) Delete(ctx context.Context, clusterName string) error { clusterDir := path.Join("clusters", clusterName) objects, err := b.Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ diff --git a/providers/backend/s3/s3_test.go b/providers/backend/s3/s3_test.go index 303ce2e..f2dc421 100644 --- a/providers/backend/s3/s3_test.go +++ b/providers/backend/s3/s3_test.go @@ -10,7 +10,7 @@ import ( v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/aws/aws-sdk-go-v2/service/s3" s3Types "github.com/aws/aws-sdk-go-v2/service/s3/types" - "github.com/aws/smithy-go" + smithy "github.com/aws/smithy-go" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" v1 "k8s.io/client-go/tools/clientcmd/api/v1" @@ -158,7 +158,7 @@ clusters: ctrl := gomock.NewController(t) mock := mockClient.NewMockS3Client(ctrl) ctx := context.Background() - testBackend := NewBackend() + testBackend := NewBackend(false) testBackend.BucketName = tc.bucketName testBackend.Client = tc.mockClient(ctx, t, mock) actualConfig, err := testBackend.Read(ctx, tc.clusterName) @@ -225,7 +225,7 @@ users: null ctrl := gomock.NewController(t) mock := mockClient.NewMockS3Client(ctrl) ctx := context.Background() - testBackend := NewBackend() + testBackend := NewBackend(false) testBackend.BucketName = tc.bucketName testBackend.Client = tc.mockClient(ctx, t, mock) err := testBackend.WriteConfig(ctx, tc.clusterName, &v1.Config{CurrentContext: "testContext"}) @@ -322,7 +322,7 @@ func TestS3_WriteFiles(t *testing.T) { mock := mockClient.NewMockS3Client(ctrl) mockPreSign := mockClient.NewMockPresignClient(ctrl) ctx := context.Background() - testBackend := NewBackend() + testBackend := NewBackend(false) testBackend.BucketName = tc.bucketName testBackend.Client = tc.mockClient(ctx, t, mock) testBackend.PresignClient = tc.mockPresignClient(ctx, t, mockPreSign) @@ -443,7 +443,7 @@ func TestS3_Delete(t *testing.T) { ctrl := gomock.NewController(t) mock := mockClient.NewMockS3Client(ctrl) ctx := context.Background() - testBackend := NewBackend() + testBackend := NewBackend(false) testBackend.BucketName = tc.bucketName testBackend.Client = tc.mockClient(ctx, t, mock) err := testBackend.Delete(ctx, tc.clusterName) @@ -556,7 +556,7 @@ clusters: ctrl := gomock.NewController(t) mock := mockClient.NewMockS3Client(ctrl) ctx := context.Background() - testBackend := NewBackend() + testBackend := NewBackend(false) testBackend.BucketName = tc.bucketName testBackend.Client = tc.mockClient(ctx, t, mock) clusters, err := testBackend.ListClusters(ctx) diff --git a/providers/backend/types.go b/providers/backend/types.go index 6c8da6d..2feffcb 100644 --- a/providers/backend/types.go +++ b/providers/backend/types.go @@ -3,16 +3,23 @@ package backend import ( "context" - v1 "k8s.io/client-go/tools/clientcmd/api/v1" + clientcmdapiv1 "k8s.io/client-go/tools/clientcmd/api/v1" + "capi-bootstrap/utils" capiYaml "capi-bootstrap/yaml" ) type Provider interface { PreCmd(ctx context.Context, clusterName string) error - Read(ctx context.Context, clusterName string) (*v1.Config, error) - WriteConfig(ctx context.Context, clusterName string, config *v1.Config) error + Read(ctx context.Context, clusterName string) (*clientcmdapiv1.Config, error) + WriteConfig(ctx context.Context, clusterName string, config *clientcmdapiv1.Config) error WriteFiles(ctx context.Context, clusterName string, cloudInitFile *capiYaml.Config) ([]string, error) Delete(ctx context.Context, clusterName string) error - ListClusters(context.Context) (map[string]*v1.Config, error) + ListClusters(ctx context.Context) (map[string]*clientcmdapiv1.Config, error) + EncryptFiles(ctx context.Context, clusterName string, files map[string]string) (map[string]string, error) + DecryptFiles(ctx context.Context, clusterName string, files map[string]string) (map[string]string, error) + GetCryptoBackend(ctx context.Context, clusterName string) (*utils.AgeBackend, error) + UploadClusterAgeKey(ctx context.Context, clusterName string) (*capiYaml.InitFile, error) + PrintClusterAgeKey(ctx context.Context, clusterName string) error + GetClusterAgeKeyInitFile() *capiYaml.InitFile } diff --git a/providers/controlplane/k3s/files/capi-k3s.yaml b/providers/controlplane/k3s/files/capi-k3s.yaml index 3ca81ec..d6d2f5f 100644 --- a/providers/controlplane/k3s/files/capi-k3s.yaml +++ b/providers/controlplane/k3s/files/capi-k3s.yaml @@ -15,8 +15,9 @@ metadata: name: k3s namespace: capi-k3s-bootstrap-system spec: + version: v0.3.0 fetchConfig: - url: https://github.com/k3s-io/cluster-api-k3s/releases/latest/bootstrap-components.yaml + url: https://github.com/k3s-io/cluster-api-k3s/releases/v0.3.0/bootstrap-components.yaml --- apiVersion: operator.cluster.x-k8s.io/v1alpha2 kind: ControlPlaneProvider @@ -24,5 +25,6 @@ metadata: name: k3s namespace: capi-k3s-control-plane-system spec: + version: v0.3.0 fetchConfig: - url: https://github.com/k3s-io/cluster-api-k3s/releases/latest/control-plane-components.yaml \ No newline at end of file + url: https://github.com/k3s-io/cluster-api-k3s/releases/v0.3.0/control-plane-components.yaml \ No newline at end of file diff --git a/providers/controlplane/k3s/k3s.go b/providers/controlplane/k3s/k3s.go index 3dd469d..1b71cd5 100644 --- a/providers/controlplane/k3s/k3s.go +++ b/providers/controlplane/k3s/k3s.go @@ -20,7 +20,7 @@ import ( v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientv1 "k8s.io/client-go/tools/clientcmd/api/v1" - "k8s.io/klog/v2" + klog "k8s.io/klog/v2" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/util/certs" "sigs.k8s.io/controller-runtime/pkg/client" @@ -280,6 +280,11 @@ func (p *ControlPlane) GetKubeconfig(ctx context.Context, values *types.Values) if err != nil { return nil, err } + + if kubeconfigBytes == nil { + return nil, fmt.Errorf("failed to marshal kubeconfig from values") + } + kubeconfigSecret := &v1.Secret{ TypeMeta: metav1.TypeMeta{ Kind: "Secret", @@ -301,10 +306,8 @@ func (p *ControlPlane) GetKubeconfig(ctx context.Context, values *types.Values) return nil, err } - kubeconfigFile := capiYaml.InitFile{ + return &capiYaml.InitFile{ Path: path.Join(values.BootstrapManifestDir + "kubeconfig-secret.yaml"), Content: string(secretBytes), - } - - return &kubeconfigFile, nil + }, nil } diff --git a/providers/infrastructure/linode/files/capi-linode.yaml b/providers/infrastructure/linode/files/capi-linode.yaml index b09c64d..9eaf34d 100644 --- a/providers/infrastructure/linode/files/capi-linode.yaml +++ b/providers/infrastructure/linode/files/capi-linode.yaml @@ -19,8 +19,8 @@ metadata: name: linode namespace: capl-system spec: - version: v0.6.0 + version: v0.9.14 fetchConfig: - url: https://github.com/linode/cluster-api-provider-linode/releases/latest/infrastructure-components.yaml + url: https://github.com/linode/cluster-api-provider-linode/releases/v0.9.14/infrastructure-components.yaml configSecret: name: capl-variables diff --git a/providers/infrastructure/linode/files/linode-ccm-vpc.yaml b/providers/infrastructure/linode/files/linode-ccm-vpc.yaml index bc268ad..9803eae 100644 --- a/providers/infrastructure/linode/files/linode-ccm-vpc.yaml +++ b/providers/infrastructure/linode/files/linode-ccm-vpc.yaml @@ -6,11 +6,12 @@ metadata: name: ccm-linode spec: targetNamespace: kube-system - version: v0.4.10 + version: v0.9.4 chart: ccm-linode repo: https://linode.github.io/linode-cloud-controller-manager/ bootstrap: true valuesContent: |- + vpcNames: [[[ .ClusterName ]]] routeController: vpcName: [[[ .ClusterName ]]] clusterCIDR: 10.0.0.0/8 diff --git a/providers/infrastructure/linode/files/linode-ccm.yaml b/providers/infrastructure/linode/files/linode-ccm.yaml index 2a0f910..e0cb666 100644 --- a/providers/infrastructure/linode/files/linode-ccm.yaml +++ b/providers/infrastructure/linode/files/linode-ccm.yaml @@ -6,7 +6,7 @@ metadata: name: ccm-linode spec: targetNamespace: kube-system - version: v0.4.10 + version: v0.9.4 chart: ccm-linode repo: https://linode.github.io/linode-cloud-controller-manager/ bootstrap: true diff --git a/providers/infrastructure/linode/linode.go b/providers/infrastructure/linode/linode.go index dd02120..9023e87 100644 --- a/providers/infrastructure/linode/linode.go +++ b/providers/infrastructure/linode/linode.go @@ -6,13 +6,14 @@ import ( "encoding/json" "errors" "fmt" + "math" "os" "path/filepath" "github.com/google/uuid" "github.com/linode/cluster-api-provider-linode/api/v1alpha2" "github.com/linode/linodego" - "k8s.io/klog/v2" + klog "k8s.io/klog/v2" "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/yaml" @@ -106,7 +107,7 @@ func (p *Infrastructure) PreDeploy(ctx context.Context, values *types.Values) er return fmt.Errorf("unable to list NodeBalancers: %s", err) } if len(existingNB) != 0 { - return errors.New("node balancer already exists") + return fmt.Errorf("node balancer %s already exists with ID %d", *existingNB[0].Label, existingNB[0].ID) } // Create a NodeBalancer p.NodeBalancer, err = p.Client.CreateNodeBalancer(ctx, linodego.NodeBalancerCreateOptions{ @@ -117,7 +118,7 @@ func (p *Infrastructure) PreDeploy(ctx context.Context, values *types.Values) er if err != nil { return fmt.Errorf("unable to create NodeBalancer: %s", err) } - klog.Infof("Created NodeBalancer: %v\n", *p.NodeBalancer.Label) + klog.Infof("Created NodeBalancer %v with ID %d\n", *p.NodeBalancer.Label, p.NodeBalancer.ID) // Create a NodeBalancer Config p.NodeBalancerConfig, err = p.Client.CreateNodeBalancerConfig(ctx, p.NodeBalancer.ID, linodego.NodeBalancerConfigCreateOptions{ @@ -156,7 +157,7 @@ func (p *Infrastructure) Deploy(ctx context.Context, values *types.Values, metad if p.VPC != nil { var vpc *linodego.VPC - var vpcSubnets []linodego.VPCSubnetCreateOptions + vpcSubnets := make([]linodego.VPCSubnetCreateOptions, 0, len(p.VPC.Spec.Subnets)) for _, subnet := range p.VPC.Spec.Subnets { vpcSubnets = append(vpcSubnets, linodego.VPCSubnetCreateOptions{ Label: subnet.Label, @@ -172,6 +173,7 @@ func (p *Infrastructure) Deploy(ctx context.Context, values *types.Values, metad if err != nil { return fmt.Errorf("unable to create VPC: %s", err) } + klog.Infof("Created VPC %v with ID %d\n", vpc.Label, vpc.ID) natAny := "any" createOptions.Interfaces = []linodego.InstanceConfigInterfaceCreateOptions{ { @@ -194,13 +196,19 @@ func (p *Infrastructure) Deploy(ctx context.Context, values *types.Values, metad return fmt.Errorf("unable to create Instance: %s", err) } - klog.Infof("Created Linode Instance: %v\n", instance.Label) - - var privateIP string + klog.Infof("Created Linode Instance %s with ID %d\n", instance.Label, instance.ID) + var ( + privateIP string + pubIP string + ) for _, ip := range instance.IPv4 { if ip.IsPrivate() { privateIP = ip.String() + continue + } else { + pubIP = ip.String() + continue } } @@ -214,8 +222,8 @@ func (p *Infrastructure) Deploy(ctx context.Context, values *types.Values, metad return err } - klog.Infof("Created NodeBalancer Node: %v\n", node.Label) - klog.Infof("Bootstrap Node IP: %s\n", instance.IPv4[0].String()) + klog.Infof("Added Node %s as Backend for NodeBalancer %s (ID: %d)\n", node.Label, *p.NodeBalancer.Label, p.NodeBalancer.ID) + klog.Infof("Bootstrap Node %s IP: %s ID: %d\n", node.Label, pubIP, node.ID) return nil } @@ -333,6 +341,10 @@ func (p *Infrastructure) UpdateManifests(ctx context.Context, manifests []string return err } if LinodeCluster.Kind == "LinodeCluster" { + // addressing gosec 'G115: integer overflow conversion int -> int32' + if p.NodeBalancerConfig.Port < 0 || p.NodeBalancerConfig.Port > math.MaxInt32 { + return fmt.Errorf("NodeBalancerConfig port out of range: %d", p.NodeBalancerConfig.Port) + } LinodeCluster.Spec.ControlPlaneEndpoint = v1beta1.APIEndpoint{ Host: values.ClusterEndpoint, Port: int32(p.NodeBalancerConfig.Port), diff --git a/state/types.go b/state/types.go index 24d6f42..ecbf84f 100644 --- a/state/types.go +++ b/state/types.go @@ -4,7 +4,7 @@ import ( "encoding/json" "k8s.io/apimachinery/pkg/runtime" - v1 "k8s.io/client-go/tools/clientcmd/api/v1" + clientcmdapiv1 "k8s.io/client-go/tools/clientcmd/api/v1" "capi-bootstrap/providers/backend" "capi-bootstrap/providers/controlplane" @@ -17,14 +17,14 @@ const ( ) type State struct { - config *v1.Config + config *clientcmdapiv1.Config Values *types.Values Infrastructure infrastructure.Provider Backend backend.Provider ControlPlane controlplane.Provider } -func NewState(config *v1.Config) (*State, error) { +func NewState(config *clientcmdapiv1.Config) (*State, error) { s := &State{ config: config, } @@ -41,7 +41,7 @@ func NewState(config *v1.Config) (*State, error) { return s, nil } -func (s *State) ToConfig() (*v1.Config, error) { +func (s *State) ToConfig() (*clientcmdapiv1.Config, error) { config := s.config raw, err := json.Marshal(s) @@ -53,7 +53,7 @@ func (s *State) ToConfig() (*v1.Config, error) { removeExtension(config) // replace with current state contents - config.Extensions = append(config.Extensions, v1.NamedExtension{ + config.Extensions = append(config.Extensions, clientcmdapiv1.NamedExtension{ Name: ExtensionName, Extension: runtime.RawExtension{ Raw: raw, @@ -84,7 +84,7 @@ func (s *State) UnmarshalJSON(b []byte) error { if err := json.Unmarshal(b, &whoami); err != nil { return err } - backendProvider := backend.NewProvider(whoami.Name) + backendProvider := backend.NewProvider(whoami.Name, s.Values.EncryptedState) if err := json.Unmarshal(b, &backendProvider); err != nil { return err } @@ -116,12 +116,12 @@ func (s *State) UnmarshalJSON(b []byte) error { return nil } -func removeExtension(config *v1.Config) { - newExtesions := []v1.NamedExtension{} +func removeExtension(config *clientcmdapiv1.Config) { + var newExtensions []clientcmdapiv1.NamedExtension for _, ext := range config.Extensions { if ext.Name != ExtensionName { - newExtesions = append(newExtesions, ext) + newExtensions = append(newExtensions, ext) } } - config.Extensions = newExtesions + config.Extensions = newExtensions } diff --git a/types/types.go b/types/types.go index d7641dc..12bd3f2 100644 --- a/types/types.go +++ b/types/types.go @@ -5,7 +5,7 @@ import ( "os" v1 "k8s.io/client-go/tools/clientcmd/api/v1" - "k8s.io/klog/v2" + klog "k8s.io/klog/v2" ) // Values is the struct including information parsed by all providers. @@ -37,6 +37,8 @@ type Values struct { // TarWriteFiles specifies whether a single tar files should be constructed for all write_files in order to deliver // reduce file sizes TarWriteFiles bool + // EncryptedState specifies whether the cluster state is encrypted + EncryptedState bool } type ClusterInfo struct { diff --git a/utils/crypto.go b/utils/crypto.go new file mode 100644 index 0000000..f1051e9 --- /dev/null +++ b/utils/crypto.go @@ -0,0 +1,161 @@ +package utils + +import ( + "errors" + "io/fs" + "os" + "path" + + "filippo.io/age" + klog "k8s.io/klog/v2" +) + +const ( + // ClusterAgeKeyEnv is used when interacting with a single cluster (i.e. get, get-kubeconfig, get-state, etc.) + // it must be the absolute path of the key. + ClusterAgeKeyEnv = "CLUSTER_AGE_KEY" + + // ClusterAgeKeysDirEnv is used when interacting with multiple clusters (i.e. list). + ClusterAgeKeysDirEnv = "CLUSTER_AGE_KEYS_DIR" +) + +var ( + DefaultIdentityFileBasePath string +) + +type AgeBackend struct { + LocalIdentity *age.HybridIdentity + ClusterIdentity *age.HybridIdentity + AdditionalRecipients []age.Recipient +} + +func NewAgeBackend(customDir, clusterName string) (*AgeBackend, error) { + // we look for age cluster keys in the following directories, listed in order + // 1. $CLUSTER_AGE_KEY (absolute path) + // 2. $CLUSTER_AGE_KEYS_DIR/$CLUSTER_NAME + // 3. $HOME/.capi-bootstrap/clusters/$CLUSTER_NAME + + // If keys are not found in any of those locations, we will create it in only one of these directories + // this is dependent on whether customDir (CLUSTER_AGE_KEYS_DIR) is passed to this function + // - $HOME/.capi-bootstrap/clusters/$CLUSTER_NAME + // - $CUSTOM_DIR/clusters/$CLUSTER_NAME + + // moved this from init due to the first test always using the actual HOME envvar value + if customDir == "" { + dir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + DefaultIdentityFileBasePath = path.Join(dir, ".capi-bootstrap", "clusters") + klog.Infof("using default path for identity files: %s", DefaultIdentityFileBasePath) + } else { + DefaultIdentityFileBasePath = path.Join(customDir, "clusters") + klog.Infof("using custom path for identity files: %s", DefaultIdentityFileBasePath) + } + var ( + localIdentity, clusterIdentity *age.HybridIdentity + recipients []age.Recipient + IdentityBasePath = path.Join(DefaultIdentityFileBasePath, clusterName) + localAgeFile = path.Join(IdentityBasePath, "local.age") + localRecipientFile = path.Join(IdentityBasePath, "local.txt") + clusterAgeFile = path.Join(IdentityBasePath, "cluster.age") + clusterRecipientFile = path.Join(IdentityBasePath, "cluster.txt") + additionalRecipientsFile = path.Join(IdentityBasePath, "recipients.txt") + ) + + // always attempt to create the directory - if it exists, err will be nil + err := os.MkdirAll(IdentityBasePath, 0700) + if err != nil { + return nil, err + } + + // TODO (rk) stop creating local age key by default, only create the cluster key + _, err = os.Stat(localAgeFile) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + // generate a new local identity + localIdentity, err = age.GenerateHybridIdentity() + if err != nil { + return nil, err + } + + err = os.WriteFile(localAgeFile, []byte(localIdentity.String()), 0400) + if err != nil { + return nil, err + } + err = os.WriteFile(localRecipientFile, []byte(localIdentity.Recipient().String()), 0444) + if err != nil { + return nil, err + } + } else { + return nil, err + } + } else { + klog.Infof("using existing local identity: %s", localAgeFile) + } + + //nolint:gosec + localFile, err := os.ReadFile(localAgeFile) + if err != nil { + return nil, err + } + + localIdentity, err = age.ParseHybridIdentity(string(localFile)) + if err != nil { + return nil, err + } + + _, err = os.Stat(clusterAgeFile) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + clusterIdentity, err = age.GenerateHybridIdentity() + if err != nil { + return nil, err + } + err = os.WriteFile(clusterAgeFile, []byte(clusterIdentity.String()), 0400) + if err != nil { + return nil, err + } + err = os.WriteFile(clusterRecipientFile, []byte(clusterIdentity.Recipient().String()), 0444) + if err != nil { + return nil, err + } + } else { + return nil, err + } + } else { + klog.Infof("using existing cluster identity: %s", clusterAgeFile) + } + + //nolint:gosec + clusterFile, err := os.ReadFile(clusterAgeFile) + if err != nil { + return nil, err + } + + clusterIdentity, err = age.ParseHybridIdentity(string(clusterFile)) + if err != nil { + return nil, err + } + + _, err = os.Stat(additionalRecipientsFile) + if err == nil { + //nolint:gosec + additionalRecipients, err := os.Open(additionalRecipientsFile) + if err != nil { + return nil, err + } + recipients, err = age.ParseRecipients(additionalRecipients) + if err != nil { + return nil, err + } + klog.V(4).Infof("parsed %d additional recipients from %s", len(recipients), additionalRecipientsFile) + } else { + recipients = []age.Recipient{clusterIdentity.Recipient(), localIdentity.Recipient()} + } + return &AgeBackend{ + LocalIdentity: localIdentity, + ClusterIdentity: clusterIdentity, + AdditionalRecipients: recipients, + }, nil +} diff --git a/utils/crypto_test.go b/utils/crypto_test.go new file mode 100644 index 0000000..9b7b685 --- /dev/null +++ b/utils/crypto_test.go @@ -0,0 +1,132 @@ +package utils + +import ( + "fmt" + "os" + "path" + "testing" + + "filippo.io/age" + "github.com/stretchr/testify/assert" + "sigs.k8s.io/cluster-api/util" +) + +func TestNewAgeBackend(t *testing.T) { + var testDir = t.TempDir() + t.Setenv("HOME", testDir) + tests := []struct { + name string + useLocalAgeFile bool + useClusterAgeFile bool + addAdditionalRecipients bool + wantErr assert.ErrorAssertionFunc + }{ + { + name: "new cluster boostrap without existing keys", + wantErr: assert.NoError, + }, + { + name: "new cluster boostrap with existing local identity", + useLocalAgeFile: true, + wantErr: assert.NoError, + }, + { + name: "new cluster boostrap with existing cluster identity", + useClusterAgeFile: true, + wantErr: assert.NoError, + }, + { + name: "new cluster boostrap with existing cluster and local identities", + useClusterAgeFile: true, + useLocalAgeFile: true, + wantErr: assert.NoError, + }, + { + name: "new cluster boostrap with additional recipients", + addAdditionalRecipients: true, + wantErr: assert.NoError, + }, + { + name: "new cluster boostrap with additional recipients and existing local identity", + useLocalAgeFile: true, + addAdditionalRecipients: true, + wantErr: assert.NoError, + }, + { + name: "new cluster boostrap with additional recipients and existing cluster identity", + useClusterAgeFile: true, + addAdditionalRecipients: true, + wantErr: assert.NoError, + }, + { + name: "new cluster boostrap with additional recipients and existing cluster and local identities", + useClusterAgeFile: true, + useLocalAgeFile: true, + addAdditionalRecipients: true, + wantErr: assert.NoError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + clusterName := util.RandomString(12) + clusterFilePath := path.Join(testDir, "clusters", clusterName) + err := os.MkdirAll(clusterFilePath, 0700) + a.NoError(err) + + if tt.addAdditionalRecipients { + recipient1, err := age.GenerateX25519Identity() + a.NoError(err) + a.NotNil(recipient1) + recipient2, err := age.GenerateX25519Identity() + a.NoError(err) + a.NotNil(recipient2) + recipientsData := []byte(fmt.Sprintf("%s\n%s\n", recipient1.Recipient().String(), recipient2.Recipient().String())) + err = os.WriteFile(path.Join(clusterFilePath, "recipients.txt"), recipientsData, 0644) + a.NoError(err) + } + + if tt.useLocalAgeFile { + localid, err := age.GenerateHybridIdentity() + a.NoError(err) + a.NotNil(localid) + err = os.WriteFile(path.Join(clusterFilePath, "local.age"), []byte(localid.String()), 0444) + a.NoError(err) + } + + if tt.useClusterAgeFile { + clusterid, err := age.GenerateHybridIdentity() + a.NoError(err) + a.NotNil(clusterid) + err = os.WriteFile(path.Join(clusterFilePath, "cluster.age"), []byte(clusterid.String()), 0444) + a.NoError(err) + } + + backend, err := NewAgeBackend(testDir, clusterName) + if !tt.wantErr(t, err, "NewAgeBackend()") { + return + } + a.NotNil(backend) + a.NotNil(backend.ClusterIdentity) + a.NotNil(backend.LocalIdentity) + if tt.addAdditionalRecipients { + a.Len(backend.AdditionalRecipients, 2) + } else { + a.Len(backend.AdditionalRecipients, 0) + } + if tt.useLocalAgeFile { + _, err = os.Stat(path.Join(clusterFilePath, "local.txt")) + a.ErrorIs(err, os.ErrNotExist) + } + if tt.useClusterAgeFile { + _, err = os.Stat(path.Join(clusterFilePath, "cluster.txt")) + a.ErrorIs(err, os.ErrNotExist) + } + a.NotNil(backend.ClusterIdentity.Recipient()) + a.NotNil(backend.LocalIdentity.Recipient()) + a.NotEmpty(backend.ClusterIdentity.String()) + a.NotEmpty(backend.LocalIdentity.String()) + }) + } +} diff --git a/utils/list.go b/utils/list.go index 3e7f44a..9216107 100644 --- a/utils/list.go +++ b/utils/list.go @@ -10,7 +10,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd" - "k8s.io/klog/v2" + klog "k8s.io/klog/v2" k8snet "k8s.io/utils/net" "sigs.k8s.io/cluster-api/api/v1beta1" diff --git a/yaml/files_test.go b/yaml/files_test.go index b457fc6..14709a2 100644 --- a/yaml/files_test.go +++ b/yaml/files_test.go @@ -115,7 +115,13 @@ spec: t.Parallel() dir, err := os.MkdirTemp("", "example") assert.NoError(t, err) - defer os.RemoveAll(dir) // clean up + defer func(path string) { + err := os.RemoveAll(path) + if err != nil { + t.Errorf("failed to remove temp dir: %v", err) + return + } + }(dir) // clean up file := filepath.Join(dir, "tmpfile") err = os.WriteFile(file, tc.manifest, 0666) assert.NoError(t, err) diff --git a/yaml/types.go b/yaml/types.go index 888cb98..b81919a 100644 --- a/yaml/types.go +++ b/yaml/types.go @@ -1,24 +1,32 @@ package yaml +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "time" +) + type InitFile struct { - Path string `yaml:"path"` - Content string `yaml:"content,omitempty"` - Source Source `yaml:"source,omitempty"` - Owner string `yaml:"owner,omitempty"` - Permissions string `yaml:"permissions,omitempty"` - Encoding string `yaml:"encoding,omitempty"` - Append bool `yaml:"append,omitempty"` - Defer bool `yaml:"defer,omitempty"` + Path string `json:"path" yaml:"path"` + Content string `json:"content,omitempty" yaml:"content,omitempty"` + Source Source `json:"source,omitempty" yaml:"source,omitempty"` + Owner string `json:"owner,omitempty" yaml:"owner,omitempty"` + Permissions string `json:"permissions,omitempty" yaml:"permissions,omitempty"` + Encoding string `json:"encoding,omitempty" yaml:"encoding,omitempty"` + Append bool `json:"append,omitempty" yaml:"append,omitempty"` + Defer bool `json:"defer,omitempty" yaml:"defer,omitempty"` } type Source struct { - URI string `yaml:"uri"` - Headers map[string]string `yaml:"headers,omitempty"` + URI string `json:"uri" yaml:"uri"` + Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` } type Config struct { - WriteFiles []InitFile `yaml:"write_files"` - RunCmd []string `yaml:"runcmd"` + WriteFiles []InitFile `json:"write_files" yaml:"write_files"` + RunCmd []string `json:"runcmd" yaml:"runcmd"` } type ParsedManifest struct { @@ -27,3 +35,44 @@ type ParsedManifest struct { PreRunCmd []string PostRunCmd []string } + +func TarFromInitFiles(files []InitFile, wipeContent bool) (data []byte, err error) { + var buf bytes.Buffer + gzipWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzipWriter) + + defer func() { + err = tarWriter.Close() // close tar writer first + if err != nil { + return + } + err = gzipWriter.Close() // close gzip writer second + if err != nil { + return + } + data, err = io.ReadAll(&buf) // capture all output + }() + + for i := range files { + header := &tar.Header{ + Name: files[i].Path[1:], + Size: int64(len(files[i].Content)), + ModTime: time.Now(), + Mode: 0o644, + } + err = tarWriter.WriteHeader(header) + if err != nil { + return data, err + } + + _, err = io.WriteString(tarWriter, files[i].Content) + if err != nil { + return data, err + } + if wipeContent { + files[i].Content = "" + } + } + + return data, err +}