diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d907e95..01a7e2a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -11,7 +11,7 @@ builds: - env: - CGO_ENABLED=0 ldflags: "-s -w -X main.Version={{ .Version }}" - main: ./cmd/ + main: . goos: - linux - darwin diff --git a/Dockerfile b/Dockerfile index c059f21..4dd2875 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ RUN go mod download COPY . . -RUN go build -o /usr/local/bin/secret-sync ./cmd/ +RUN go build -o /usr/local/bin/secret-sync . RUN xx-verify /usr/local/bin/secret-sync diff --git a/Makefile b/Makefile index 06c71de..810be9c 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ down: ## Destroy development environment .PHONY: build build: ## Build binary @mkdir -p build - go build -race -o build/secret-sync ./cmd/ + go build -race -o build/ .PHONY: container-image container-image: ## Build container image diff --git a/cmd/main.go b/cmd/root.go similarity index 80% rename from cmd/main.go rename to cmd/root.go index 518818d..b23db6e 100644 --- a/cmd/main.go +++ b/cmd/root.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package main +package cmd import ( "context" @@ -24,24 +24,34 @@ import ( slogmulti "github.com/samber/slog-multi" slogsyslog "github.com/samber/slog-syslog" + "github.com/spf13/cobra" - "github.com/bank-vaults/secret-sync/cmd/sync" "github.com/bank-vaults/secret-sync/pkg/config" ) -var Version = "v0.1.3" - -func main() { - config := config.LoadConfig() - - initLogger(config) +var rootCmd = &cobra.Command{ + Use: "secret-sync", + Long: `Secret Sync exposes a generic way to interact with external secret storage systems +like HashiCorp Vault and provides a set of API models +to interact and orchestrate the synchronization of secrets between them.`, + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, + }, +} - syncCMD := sync.NewSyncCmd(context.Background()) - if err := syncCMD.ExecuteContext(syncCMD.Context()); err != nil { - slog.ErrorContext(syncCMD.Context(), fmt.Errorf("error executing command: %w", err).Error()) +func Execute() { + if err := rootCmd.ExecuteContext(context.Background()); err != nil { + slog.ErrorContext(rootCmd.Context(), fmt.Sprintf("failed to execute command: %v", err)) + os.Exit(1) } } +func init() { + cobra.OnInitialize(func() { + initLogger(config.LoadConfig()) + }) +} + func initLogger(config *config.Config) { var level slog.Level diff --git a/cmd/sync.go b/cmd/sync.go new file mode 100644 index 0000000..5e6fdcb --- /dev/null +++ b/cmd/sync.go @@ -0,0 +1,196 @@ +// Copyright © 2024 Bank-Vaults Maintainers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "os/signal" + + "github.com/ghodss/yaml" + "github.com/krayzpipes/cronticker/cronticker" + "github.com/spf13/cobra" + + "github.com/bank-vaults/secret-sync/pkg/apis/v1alpha1" + "github.com/bank-vaults/secret-sync/pkg/provider" + "github.com/bank-vaults/secret-sync/pkg/storesync" +) + +const ( + flagSource = "source" + flagTarget = "target" + flagSyncJob = "syncjob" + flagSchedule = "schedule" +) + +var syncCmdParams = struct { + SourceStorePath string + TargetStorePath string + SyncJobPath string + Schedule string +}{} + +type syncJob struct { + source *v1alpha1.StoreClient + target *v1alpha1.StoreClient + syncPlan *v1alpha1.SyncPlan +} + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Synchronizes secrets from a source to a target store based on sync strategy.", + RunE: run, +} + +func init() { + rootCmd.AddCommand(syncCmd) + syncCmd.PersistentFlags().StringVarP(&syncCmdParams.SourceStorePath, flagSource, "s", "", "Source store config file.") + _ = syncCmd.MarkPersistentFlagRequired(flagSource) + syncCmd.PersistentFlags().StringVarP(&syncCmdParams.TargetStorePath, flagTarget, "t", "", "Target store config file. ") + _ = syncCmd.MarkPersistentFlagRequired(flagTarget) + syncCmd.PersistentFlags().StringVar(&syncCmdParams.SyncJobPath, flagSyncJob, "", "Sync job config file. ") + _ = syncCmd.MarkPersistentFlagRequired(flagSyncJob) + + syncCmd.PersistentFlags().StringVar(&syncCmdParams.Schedule, flagSchedule, "", "Sync periodically using CRON schedule. If not specified, runs only once.") +} + +func run(cmd *cobra.Command, args []string) error { + syncJob, err := prepareSync(cmd, args) + if err != nil { + return fmt.Errorf("failed to prepare sync job: %w", err) + } + + // Run once + if syncJob.syncPlan.GetSchedule(cmd.Root().Context()) == nil { + resp, err := storesync.Sync(cmd.Root().Context(), *syncJob.source, *syncJob.target, syncJob.syncPlan.SyncAction) + if err != nil { + return fmt.Errorf("failed to sync secrets: %w", err) + } + slog.InfoContext(cmd.Root().Context(), resp.Status) + + return nil + } + + // Run on schedule + cronTicker, err := cronticker.NewTicker(syncJob.syncPlan.Schedule) + if err != nil { + return fmt.Errorf("failed to create CRON ticker: %w", err) + } + defer cronTicker.Stop() + + cancel := make(chan os.Signal, 1) + signal.Notify(cancel, os.Interrupt) + for { + select { + case <-cronTicker.C: + slog.InfoContext(cmd.Root().Context(), "Handling a new sync request...") + + resp, err := storesync.Sync(cmd.Root().Context(), *syncJob.source, *syncJob.target, syncJob.syncPlan.SyncAction) + if err != nil { + return err + } + slog.InfoContext(cmd.Root().Context(), resp.Status) + + case <-cancel: + return nil + } + } +} + +func prepareSync(cmd *cobra.Command, _ []string) (*syncJob, error) { + // Init source + sourceStore, err := loadStore(syncCmdParams.SourceStorePath) + if err != nil { + return nil, fmt.Errorf("failed to load source store: %w", err) + } + + sourceProvider, err := provider.NewClient(cmd.Root().Context(), sourceStore) + if err != nil { + return nil, fmt.Errorf("failed to create source client: %w", err) + } + + // Init target + targetStore, err := loadStore(syncCmdParams.TargetStorePath) + if err != nil { + return nil, fmt.Errorf("failed to load target store: %w", err) + } + + targetProvider, err := provider.NewClient(cmd.Root().Context(), targetStore) + if err != nil { + return nil, fmt.Errorf("failed to create target client: %w", err) + } + + // Init sync request by loading from file and overriding from cli + syncPlan, err := loadSyncPlan(syncCmdParams.SyncJobPath) + if err != nil { + return nil, fmt.Errorf("failed to load sync plan: %w", err) + } + + syncPlan.Schedule = syncCmdParams.Schedule + + return &syncJob{ + source: &sourceProvider, + target: &targetProvider, + syncPlan: syncPlan, + }, nil +} + +func loadStore(path string) (*v1alpha1.SecretStoreSpec, error) { + // Load file + yamlBytes, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + // Unmarshal (convert YAML to JSON) + var storeConfig = struct { + SecretsStore v1alpha1.SecretStoreSpec `json:"secretsStore"` + }{} + + jsonBytes, err := yaml.YAMLToJSON(yamlBytes) + if err != nil { + return nil, fmt.Errorf("failed to convert YAML to JSON: %w", err) + } + + if err := json.Unmarshal(jsonBytes, &storeConfig); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) + } + + return &storeConfig.SecretsStore, nil +} + +func loadSyncPlan(path string) (*v1alpha1.SyncPlan, error) { + // Load file + yamlBytes, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + // Unmarshal (convert YAML to JSON) + var ruleCfg v1alpha1.SyncPlan + + jsonBytes, err := yaml.YAMLToJSON(yamlBytes) + if err != nil { + return nil, fmt.Errorf("failed to convert YAML to JSON: %w", err) + } + + if err := json.Unmarshal(jsonBytes, &ruleCfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) + } + + return &ruleCfg, nil +} diff --git a/cmd/sync/sync.go b/cmd/sync/sync.go deleted file mode 100644 index f5a00ab..0000000 --- a/cmd/sync/sync.go +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright © 2024 Bank-Vaults Maintainers -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sync - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "os" - "os/signal" - - "github.com/ghodss/yaml" - "github.com/krayzpipes/cronticker/cronticker" - "github.com/spf13/cobra" - - "github.com/bank-vaults/secret-sync/pkg/apis/v1alpha1" - "github.com/bank-vaults/secret-sync/pkg/provider" - "github.com/bank-vaults/secret-sync/pkg/storesync" -) - -func NewSyncCmd(ctx context.Context) *cobra.Command { - // Create cmd - cmd := &syncCmd{} - cobraCmd := &cobra.Command{ - Use: "sync", - Short: "Synchronizes secrets from a source to a target store based on sync strategy.", - RunE: func(_ *cobra.Command, _ []string) error { - if err := cmd.init(); err != nil { - return fmt.Errorf("error initializing sync command: %w", err) - } - - return cmd.run(cmd.sync) - }, - } - // ctx passed to project components - cmd.ctx = ctx - // ctx passed to cobra - cobraCmd.SetContext(ctx) - - // Register cmd flags - cobraCmd.Flags().StringVar(&cmd.flgSrcFile, "source", "", "Source store config file. "+ - "This is the store where the data will be fetched from.") - _ = cobraCmd.MarkFlagRequired("source") - cobraCmd.Flags().StringVar(&cmd.flagDstFile, "target", "", "Target store config file. "+ - "This is the store where the data will be synced to.") - _ = cobraCmd.MarkFlagRequired("target") - cobraCmd.Flags().StringVar(&cmd.flagSyncFile, "sync", "", "Sync job config file. "+ - "This is the strategy sync template.") - _ = cobraCmd.MarkFlagRequired("sync") - - cobraCmd.Flags().StringVar(&cmd.flagSchedule, "schedule", "", - "Sync periodically using CRON schedule. If not specified, runs only once.") - - return cobraCmd -} - -type syncCmd struct { - ctx context.Context - flgSrcFile string - flagDstFile string - flagSyncFile string - flagSchedule string - - source v1alpha1.StoreReader - target v1alpha1.StoreWriter - sync *v1alpha1.SyncJob -} - -func (cmd *syncCmd) init() error { - // Init source - source, err := initStore(cmd.ctx, cmd.flgSrcFile) - if err != nil { - return fmt.Errorf("error initializing source store: %w", err) - } - cmd.source = source - - // Init target - target, err := initStore(cmd.ctx, cmd.flagDstFile) - if err != nil { - return fmt.Errorf("error initializing target store: %w", err) - } - cmd.target = target - - // Init sync request by loading from file and overriding from cli - sync, err := loadSyncPlan(cmd.flagSyncFile) - if err != nil { - return fmt.Errorf("error loading sync plan: %w", err) - } - cmd.sync = sync - - if cmd.flagSchedule != "" { - cmd.sync.Schedule = cmd.flagSchedule - } - - return nil -} - -func (cmd *syncCmd) run(syncReq *v1alpha1.SyncJob) error { - // Run once - if syncReq.GetSchedule(cmd.ctx) == nil { - resp, err := storesync.Sync(cmd.ctx, cmd.source, cmd.target, syncReq.Sync) - if err != nil { - return err - } - slog.InfoContext(cmd.ctx, resp.Status) - - return nil - } - - // Run on schedule - cronTicker, err := cronticker.NewTicker(syncReq.Schedule) - if err != nil { - return err - } - defer cronTicker.Stop() - - cancel := make(chan os.Signal, 1) - signal.Notify(cancel, os.Interrupt) - for { - select { - case <-cronTicker.C: - slog.InfoContext(cmd.ctx, "Handling a new sync request...") - - resp, err := storesync.Sync(cmd.ctx, cmd.source, cmd.target, syncReq.Sync) - if err != nil { - return err - } - slog.InfoContext(cmd.ctx, resp.Status) - - case <-cancel: - return nil - } - } -} - -func initStore(ctx context.Context, path string) (v1alpha1.StoreClient, error) { - store, err := loadStore(path) - if err != nil { - return nil, fmt.Errorf("error loading store: %w", err) - } - - client, err := provider.NewClient(ctx, store) - if err != nil { - return nil, fmt.Errorf("error creating client: %w", err) - } - - return client, nil -} - -func loadStore(path string) (*v1alpha1.SecretStoreSpec, error) { - // Load file - yamlBytes, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - // Unmarshal (convert YAML to JSON) - var storeConfig = struct { - SecretsStore v1alpha1.SecretStoreSpec `json:"secretsStore"` - }{} - - jsonBytes, err := yaml.YAMLToJSON(yamlBytes) - if err != nil { - return nil, err - } - - if err := json.Unmarshal(jsonBytes, &storeConfig); err != nil { - return nil, err - } - - return &storeConfig.SecretsStore, nil -} - -func loadSyncPlan(path string) (*v1alpha1.SyncJob, error) { - // Load file - yamlBytes, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - // Unmarshal (convert YAML to JSON) - var ruleCfg v1alpha1.SyncJob - jsonBytes, err := yaml.YAMLToJSON(yamlBytes) - if err != nil { - return nil, err - } - - if err := json.Unmarshal(jsonBytes, &ruleCfg); err != nil { - return nil, err - } - - return &ruleCfg, nil -} diff --git a/cmd/sync/sync_test.go b/cmd/sync_test.go similarity index 92% rename from cmd/sync/sync_test.go rename to cmd/sync_test.go index a518f1a..b1f03b5 100644 --- a/cmd/sync/sync_test.go +++ b/cmd/sync_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package sync +package cmd import ( "context" @@ -31,7 +31,6 @@ secretsStore: ` // TODO: Expand tests - func TestSync(t *testing.T) { tests := []struct { name string @@ -50,14 +49,13 @@ func TestSync(t *testing.T) { for _, tt := range tests { ttp := tt t.Run(ttp.name, func(t *testing.T) { - syncCMD := NewSyncCmd(context.Background()) - syncCMD.SetArgs([]string{ + syncCmd.SetArgs([]string{ "--source", ttp.source, "--target", ttp.target, "--sync", ttp.sync, }) - err := syncCMD.ExecuteContext(syncCMD.Context()) + err := syncCmd.ExecuteContext(context.Background()) require.NoError(t, err, "Unexpected error") }) } diff --git a/cmd/sync/testdata/source/credentials/password b/cmd/testdata/source/credentials/password similarity index 100% rename from cmd/sync/testdata/source/credentials/password rename to cmd/testdata/source/credentials/password diff --git a/cmd/sync/testdata/source/credentials/username b/cmd/testdata/source/credentials/username similarity index 100% rename from cmd/sync/testdata/source/credentials/username rename to cmd/testdata/source/credentials/username diff --git a/cmd/sync/testdata/store-file-dest.yaml b/cmd/testdata/store-file-dest.yaml similarity index 100% rename from cmd/sync/testdata/store-file-dest.yaml rename to cmd/testdata/store-file-dest.yaml diff --git a/cmd/sync/testdata/store-file-source.yaml b/cmd/testdata/store-file-source.yaml similarity index 100% rename from cmd/sync/testdata/store-file-source.yaml rename to cmd/testdata/store-file-source.yaml diff --git a/cmd/sync/testdata/store-vault.yaml b/cmd/testdata/store-vault.yaml similarity index 100% rename from cmd/sync/testdata/store-vault.yaml rename to cmd/testdata/store-vault.yaml diff --git a/cmd/sync/testdata/syncjob.yaml b/cmd/testdata/syncjob.yaml similarity index 100% rename from cmd/sync/testdata/syncjob.yaml rename to cmd/testdata/syncjob.yaml diff --git a/main.go b/main.go new file mode 100644 index 0000000..dcf7da7 --- /dev/null +++ b/main.go @@ -0,0 +1,23 @@ +// Copyright © 2024 Bank-Vaults Maintainers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import "github.com/bank-vaults/secret-sync/cmd" + +var Version = "v0.1.3" + +func main() { + cmd.Execute() +} diff --git a/pkg/apis/v1alpha1/secretstore_schema.go b/pkg/apis/v1alpha1/secretstore_schema.go index cf8604c..0e83d89 100644 --- a/pkg/apis/v1alpha1/secretstore_schema.go +++ b/pkg/apis/v1alpha1/secretstore_schema.go @@ -17,7 +17,6 @@ package v1alpha1 import ( "errors" "fmt" - "log/slog" "reflect" "sync" ) @@ -42,8 +41,6 @@ func Register(store SecretStore, backend *SecretStoreSpec) { } stores[storeName] = store - - slog.Info(fmt.Sprintf("registered store backend %s", storeName)) } // GetSecretStore returns the SecretStore for given SecretStoreSpec. diff --git a/pkg/apis/v1alpha1/syncjob_types.go b/pkg/apis/v1alpha1/syncjob_types.go index e1b705c..456eff1 100644 --- a/pkg/apis/v1alpha1/syncjob_types.go +++ b/pkg/apis/v1alpha1/syncjob_types.go @@ -26,9 +26,9 @@ import ( var DefaultSyncJobAuditLogPath = filepath.Join(os.TempDir(), "sync-audit.log") -// SyncJob defines overall source-to-target sync strategy. +// SyncPlan defines overall source-to-target sync strategy. // TODO: Add support for auditing. -type SyncJob struct { +type SyncPlan struct { // Points to a file where all sync logs should be saved to. // Defaults to DefaultSyncJobAuditLogPath // Optional @@ -42,10 +42,10 @@ type SyncJob struct { // Used to specify the strategy for secrets sync. // Required - Sync []SyncAction `json:"sync,omitempty"` + SyncAction []SyncAction `json:"sync,omitempty"` } -func (spec *SyncJob) GetSchedule(ctx context.Context) *string { +func (spec *SyncPlan) GetSchedule(ctx context.Context) *string { if spec.Schedule == "" { return nil } @@ -58,7 +58,7 @@ func (spec *SyncJob) GetSchedule(ctx context.Context) *string { return &spec.Schedule } -func (spec *SyncJob) GetAuditLogPath() string { +func (spec *SyncPlan) GetAuditLogPath() string { if spec.AuditLogPath == "" { return DefaultSyncJobAuditLogPath } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 06b704d..6b8e276 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -50,8 +50,7 @@ func TestConfig(t *testing.T) { } defer os.Clearenv() - config := LoadConfig() - assert.Equal(t, ttp.wantConfig, config, "Unexpected config") + assert.Equal(t, ttp.wantConfig, LoadConfig(), "Unexpected config") }) } }