From ee1185b8e3d86407b4eb9ec12d9394fd1d211e49 Mon Sep 17 00:00:00 2001 From: drumato Date: Fri, 15 May 2026 19:24:32 +0900 Subject: [PATCH 01/13] chore(cmd): remove dead --kustomize flag from root command The --kustomize/-k flag was defined on rootCmd but never read via GetString("kustomize") anywhere in the codebase. Removing it avoids confusing help output and frees up the -k shorthand for future use. Closes #28 --- cmd/root.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 8f23e44..b32cc7c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -38,7 +38,6 @@ func Execute() { } func init() { - rootCmd.Flags().StringP("kustomize", "k", "", "Process kustomize directory") rootCmd.PersistentFlags().StringP("file-path", "f", "tazuna.yaml", "Path to tazuna.yaml") rootCmd.PersistentFlags().StringP("log-level", "l", "info", "log level(debug/info/warn/error)") } From 6d875ed1de9a369e5ba996eb88d2c97109eacdcf Mon Sep 17 00:00:00 2001 From: drumato Date: Fri, 15 May 2026 19:26:34 +0900 Subject: [PATCH 02/13] chore(api/v1): remove misleading GetKind/GetName from GenesisSecret These methods masqueraded as runtime.Object accessors but the type does not implement that interface, and GetName always returned "". Readers could be misled into thinking GenesisSecret carried an identity; the empty-string GetName would silently break any caller that trusted it. No production code referenced either method. Closes #33 --- api/v1/genesissecret_types.go | 7 ------- api/v1/genesissecret_types_test.go | 10 ---------- 2 files changed, 17 deletions(-) diff --git a/api/v1/genesissecret_types.go b/api/v1/genesissecret_types.go index 97a0e4f..23da448 100644 --- a/api/v1/genesissecret_types.go +++ b/api/v1/genesissecret_types.go @@ -38,10 +38,3 @@ type GenesisSecretOutputKubernetesSecret struct { // corev1.SecretType を指定する Type string `yaml:"type"` } - -func (GenesisSecret) GetKind() string { - return "GenesisSecret" -} -func (GenesisSecret) GetName() string { - return "" -} diff --git a/api/v1/genesissecret_types_test.go b/api/v1/genesissecret_types_test.go index e08742f..e099e36 100644 --- a/api/v1/genesissecret_types_test.go +++ b/api/v1/genesissecret_types_test.go @@ -98,13 +98,3 @@ func TestGenesisSecretOutput_StdoutOnly(t *testing.T) { } } -func TestGenesisSecret_Kind(t *testing.T) { - t.Parallel() - gs := GenesisSecret{} - if gs.GetKind() != "GenesisSecret" { - t.Errorf("GetKind = %q", gs.GetKind()) - } - if gs.GetName() != "" { - t.Errorf("GetName = %q, want empty", gs.GetName()) - } -} From baaa0283d776975e1319405b17fefe2880344059 Mon Sep 17 00:00:00 2001 From: drumato Date: Fri, 15 May 2026 19:28:27 +0900 Subject: [PATCH 03/13] fix(cmd): honor --log-level in check, not io.Discard check uniquely piped the runner logger to io.Discard, so --log-level silently had no effect and any validation diagnostics from the runner were swallowed. Match the apply/build/destroy pattern: parse --log-level and write structured logs to stderr. Closes #31 --- cmd/check.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/cmd/check.go b/cmd/check.go index f8eae2c..b92961a 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -2,10 +2,10 @@ package cmd import ( "fmt" - "io" "log/slog" "os" "path/filepath" + "strings" "github.com/cockroachdb/errors" v1 "github.com/pepabo/tazuna/api/v1" @@ -66,7 +66,22 @@ Examples: return errors.Wrapf(err, "validation failed for tazuna.yaml at %s", path) } - logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})) + logLevelS, err := cmd.Flags().GetString("log-level") + if err != nil { + return errors.WithStack(err) + } + var logLevel slog.Level + switch strings.ToLower(logLevelS) { + case "debug": + logLevel = slog.LevelDebug + case "warn": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + default: + logLevel = slog.LevelInfo + } + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) r := runner.NewTazunaRunner(logger, nil, nil) if fix { From 29d55d46d6d6bb998b9f54776b8f8de07f3848cb Mon Sep 17 00:00:00 2001 From: drumato Date: Fri, 15 May 2026 19:32:26 +0900 Subject: [PATCH 04/13] fix(api/v1): validate Tazuna apiVersion and kind Tazuna lacked TypeMeta fields, so 'apiVersion: totally.wrong/v999' and 'kind: NotTazuna' parsed cleanly and 'tazuna check' returned ok. Add APIVersion/Kind to the schema with omitempty, define the expected values as constants, and have the validator reject mismatches when they are set. Empty values stay accepted for backward compatibility with tazuna.yaml files written before this field existed. Closes #25 --- api/v1/tazuna_types.go | 13 ++++++ pkg/validator/validator.go | 21 +++++++++ pkg/validator/validator_test.go | 83 +++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) diff --git a/api/v1/tazuna_types.go b/api/v1/tazuna_types.go index 7625623..42708b9 100644 --- a/api/v1/tazuna_types.go +++ b/api/v1/tazuna_types.go @@ -1,7 +1,20 @@ package v1 +const ( + // TazunaAPIVersion は Tazuna リソースが取りうる apiVersion の正規値です。 + TazunaAPIVersion = "tazuna.pepabo.com/v1" + // TazunaKind は Tazuna リソースが取りうる kind の正規値です。 + TazunaKind = "Tazuna" +) + // Tazuna はtazuna applyの挙動を制御するルートリソースです type Tazuna struct { + // APIVersion は Kubernetes manifest と同形式の TypeMeta フィールドです。 + // 設定する場合は TazunaAPIVersion と一致している必要があります。 + APIVersion string `yaml:"apiVersion,omitempty"` + // Kind は Kubernetes manifest と同形式の TypeMeta フィールドです。 + // 設定する場合は TazunaKind と一致している必要があります。 + Kind string `yaml:"kind,omitempty"` Spec TazunaSpec `yaml:"spec"` } diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index d29ffc9..b6d8dd6 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -15,6 +15,10 @@ func ValidateTazuna(tazuna *v1.Tazuna) error { return errors.New("tazuna is nil") } + if err := ValidateTazunaTypeMeta(tazuna); err != nil { + return errors.WithStack(err) + } + if err := ValidateTazunaSpec(&tazuna.Spec, ""); err != nil { return errors.WithStack(err) } @@ -28,6 +32,10 @@ func ValidateTazunaWithBasePath(tazuna *v1.Tazuna, basePath string) error { return errors.New("tazuna is nil") } + if err := ValidateTazunaTypeMeta(tazuna); err != nil { + return errors.WithStack(err) + } + if err := ValidateTazunaSpec(&tazuna.Spec, basePath); err != nil { return errors.WithStack(err) } @@ -35,6 +43,19 @@ func ValidateTazunaWithBasePath(tazuna *v1.Tazuna, basePath string) error { return nil } +// ValidateTazunaTypeMeta は Tazuna の apiVersion / kind を検証します。 +// 後方互換のため未設定 (空文字) は許容しますが、値が設定されている場合は +// v1.TazunaAPIVersion / v1.TazunaKind と完全一致している必要があります。 +func ValidateTazunaTypeMeta(tazuna *v1.Tazuna) error { + if tazuna.APIVersion != "" && tazuna.APIVersion != v1.TazunaAPIVersion { + return errors.Errorf("apiVersion must be %q, got %q", v1.TazunaAPIVersion, tazuna.APIVersion) + } + if tazuna.Kind != "" && tazuna.Kind != v1.TazunaKind { + return errors.Errorf("kind must be %q, got %q", v1.TazunaKind, tazuna.Kind) + } + return nil +} + // ValidateTazunaSpec は TazunaSpec をバリデーションします func ValidateTazunaSpec(spec *v1.TazunaSpec, basePath string) error { if spec == nil { diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go index 3aebbef..1850053 100644 --- a/pkg/validator/validator_test.go +++ b/pkg/validator/validator_test.go @@ -529,6 +529,89 @@ func TestValidateTazunaSpec_ContextMatches(t *testing.T) { } } +func TestValidateTazunaTypeMeta(t *testing.T) { + t.Parallel() + tests := []struct { + name string + tazuna *v1.Tazuna + expectErr bool + errMsg string + }{ + { + name: "both fields empty (backward compat)", + tazuna: &v1.Tazuna{}, + expectErr: false, + }, + { + name: "both fields set to expected values", + tazuna: &v1.Tazuna{ + APIVersion: v1.TazunaAPIVersion, + Kind: v1.TazunaKind, + }, + expectErr: false, + }, + { + name: "only apiVersion set and correct", + tazuna: &v1.Tazuna{ + APIVersion: v1.TazunaAPIVersion, + }, + expectErr: false, + }, + { + name: "only kind set and correct", + tazuna: &v1.Tazuna{ + Kind: v1.TazunaKind, + }, + expectErr: false, + }, + { + name: "apiVersion wrong", + tazuna: &v1.Tazuna{ + APIVersion: "totally.wrong/v999", + Kind: v1.TazunaKind, + }, + expectErr: true, + errMsg: "apiVersion must be", + }, + { + name: "kind wrong", + tazuna: &v1.Tazuna{ + APIVersion: v1.TazunaAPIVersion, + Kind: "NotTazuna", + }, + expectErr: true, + errMsg: "kind must be", + }, + { + name: "both wrong - apiVersion reported first", + tazuna: &v1.Tazuna{ + APIVersion: "totally.wrong/v999", + Kind: "NotTazuna", + }, + expectErr: true, + errMsg: "apiVersion must be", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateTazunaTypeMeta(tt.tazuna) + if tt.expectErr { + if err == nil { + t.Errorf("expected error but got nil") + return + } + if tt.errMsg != "" && !containsString(err.Error(), tt.errMsg) { + t.Errorf("expected error message to contain %q, got: %s", tt.errMsg, err.Error()) + } + } else if err != nil { + t.Errorf("expected no error but got: %v", err) + } + }) + } +} + func containsString(haystack, needle string) bool { return len(haystack) >= len(needle) && (haystack == needle || len(needle) == 0 || From 8fcfc16ed301ba292737f3d7af464ad3cc0b7bcc Mon Sep 17 00:00:00 2001 From: drumato Date: Fri, 15 May 2026 19:35:06 +0900 Subject: [PATCH 05/13] fix(cmd): preserve original error when defer f.Close fails Every subcommand opened tazuna.yaml with the same defer pattern, but the close branch overwrote any error already accumulated by Decode or later steps. A spurious 'file already closed' on shutdown would silently mask the real YAML parse or runtime failure, making bugs much harder to diagnose. Wrap with errors.Join so both errors survive. Closes #26 --- cmd/apply.go | 2 +- cmd/build.go | 2 +- cmd/check.go | 2 +- cmd/destroy.go | 2 +- cmd/state_diff.go | 2 +- cmd/state_list.go | 2 +- cmd/state_sync.go | 2 +- cmd/tags.go | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index 523ab26..8ebddd1 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -85,7 +85,7 @@ Examples: } defer func() { if cerr := f.Close(); cerr != nil { - err = errors.WithStack(cerr) + err = errors.Join(err, errors.WithStack(cerr)) } }() diff --git a/cmd/build.go b/cmd/build.go index 7eb9d50..46d0ed6 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -78,7 +78,7 @@ Examples: } defer func() { if cerr := f.Close(); cerr != nil { - err = errors.WithStack(cerr) + err = errors.Join(err, errors.WithStack(cerr)) } }() diff --git a/cmd/check.go b/cmd/check.go index b92961a..d87d05a 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -48,7 +48,7 @@ Examples: } defer func() { if cerr := f.Close(); cerr != nil { - err = errors.WithStack(cerr) + err = errors.Join(err, errors.WithStack(cerr)) } }() diff --git a/cmd/destroy.go b/cmd/destroy.go index 84cb7aa..2b15154 100644 --- a/cmd/destroy.go +++ b/cmd/destroy.go @@ -82,7 +82,7 @@ Examples: } defer func() { if cerr := f.Close(); cerr != nil { - err = errors.WithStack(cerr) + err = errors.Join(err, errors.WithStack(cerr)) } }() diff --git a/cmd/state_diff.go b/cmd/state_diff.go index cd0967a..360f32c 100644 --- a/cmd/state_diff.go +++ b/cmd/state_diff.go @@ -65,7 +65,7 @@ Examples: } defer func() { if cerr := f.Close(); cerr != nil { - err = errors.WithStack(cerr) + err = errors.Join(err, errors.WithStack(cerr)) } }() diff --git a/cmd/state_list.go b/cmd/state_list.go index 3fab875..14871b0 100644 --- a/cmd/state_list.go +++ b/cmd/state_list.go @@ -66,7 +66,7 @@ Examples: } defer func() { if cerr := f.Close(); cerr != nil { - err = errors.WithStack(cerr) + err = errors.Join(err, errors.WithStack(cerr)) } }() diff --git a/cmd/state_sync.go b/cmd/state_sync.go index 6a52905..40f132f 100644 --- a/cmd/state_sync.go +++ b/cmd/state_sync.go @@ -73,7 +73,7 @@ Examples: } defer func() { if cerr := f.Close(); cerr != nil { - err = errors.WithStack(cerr) + err = errors.Join(err, errors.WithStack(cerr)) } }() diff --git a/cmd/tags.go b/cmd/tags.go index 0ca0860..9adb5c0 100644 --- a/cmd/tags.go +++ b/cmd/tags.go @@ -32,7 +32,7 @@ Examples: } defer func() { if cerr := f.Close(); cerr != nil { - err = errors.WithStack(cerr) + err = errors.Join(err, errors.WithStack(cerr)) } }() From fd2c57b9f195204bcdaf89fd910657474d27ed90 Mon Sep 17 00:00:00 2001 From: drumato Date: Fri, 15 May 2026 19:37:41 +0900 Subject: [PATCH 06/13] fix(runner): stop ConvertManifestPathFromCwd from mutating caller's Manifests Apply and friends receive Tazuna by value, but the slice header still shares its backing array with the caller. ConvertManifestPathFromCwd indexed into that shared array, so feeding the same Tazuna into a runner twice prefixed baseDir twice and produced baseDir/baseDir/. Allocate a fresh slice and mutate the copy, leaving the caller's input untouched. Add tests that pin both invariants. Closes #30 --- pkg/runner/tazuna.go | 16 ++++++++-- pkg/runner/tazuna_test.go | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 pkg/runner/tazuna_test.go diff --git a/pkg/runner/tazuna.go b/pkg/runner/tazuna.go index 755cc09..0f55324 100644 --- a/pkg/runner/tazuna.go +++ b/pkg/runner/tazuna.go @@ -85,11 +85,21 @@ func setupTestPlugins(k8sClient client.Client) map[string]testplugin.Plugin { return m } +// ConvertManifestPathFromCwd は tazuna.yaml からの相対パスを cwd 起点のパスに +// 書き換えます。呼び出し元の Manifests スライスを破壊しないよう、専用の +// バッキング配列にコピーしてから書き換えます。Apply 等が Tazuna を値で受け取って +// いてもスライスヘッダはバッキング配列を共有するため、コピーしないと同じ +// Tazuna を二度渡すと baseDir が二重に prefix される問題が起きます。 func (t *TazunaRunner) ConvertManifestPathFromCwd(baseDir string, tazuna *v1.Tazuna) { - for mi := range tazuna.Spec.Manifests { - manifestPathFromCwd := filepath.Join(baseDir, tazuna.Spec.Manifests[mi].Path) - tazuna.Spec.Manifests[mi].Path = manifestPathFromCwd + if len(tazuna.Spec.Manifests) == 0 { + return } + copied := make([]v1.Manifest, len(tazuna.Spec.Manifests)) + copy(copied, tazuna.Spec.Manifests) + for mi := range copied { + copied[mi].Path = filepath.Join(baseDir, copied[mi].Path) + } + tazuna.Spec.Manifests = copied } func WithTags(tags []string) RunnerOption { diff --git a/pkg/runner/tazuna_test.go b/pkg/runner/tazuna_test.go new file mode 100644 index 0000000..674f671 --- /dev/null +++ b/pkg/runner/tazuna_test.go @@ -0,0 +1,67 @@ +package runner_test + +import ( + "testing" + + v1 "github.com/pepabo/tazuna/api/v1" + "github.com/pepabo/tazuna/pkg/runner" +) + +func TestConvertManifestPathFromCwd_DoesNotMutateInput(t *testing.T) { + t.Parallel() + + r := runner.NewTazunaRunner(nil, nil, nil) + + original := v1.Tazuna{ + Spec: v1.TazunaSpec{ + Manifests: []v1.Manifest{ + {Name: "a", Type: v1.ManifestTypeKustomize, Path: "./a"}, + {Name: "b", Type: v1.ManifestTypeKustomize, Path: "./b"}, + }, + }, + } + + // 値コピーは Manifests のスライスヘッダのみで、バッキング配列を共有する。 + // 修正前は、ConvertManifestPathFromCwd が backing 配列の要素を直接書き換えるため + // original 側まで巻き込まれていた。 + cp := original + r.ConvertManifestPathFromCwd("/base", &cp) + + if got, want := original.Spec.Manifests[0].Path, "./a"; got != want { + t.Errorf("original.Manifests[0].Path mutated: got %q, want %q", got, want) + } + if got, want := original.Spec.Manifests[1].Path, "./b"; got != want { + t.Errorf("original.Manifests[1].Path mutated: got %q, want %q", got, want) + } + + if got, want := cp.Spec.Manifests[0].Path, "/base/a"; got != want { + t.Errorf("cp.Manifests[0].Path not converted: got %q, want %q", got, want) + } + if got, want := cp.Spec.Manifests[1].Path, "/base/b"; got != want { + t.Errorf("cp.Manifests[1].Path not converted: got %q, want %q", got, want) + } +} + +func TestConvertManifestPathFromCwd_RepeatedCallsAreStableAcrossInputs(t *testing.T) { + t.Parallel() + + r := runner.NewTazunaRunner(nil, nil, nil) + + original := v1.Tazuna{ + Spec: v1.TazunaSpec{ + Manifests: []v1.Manifest{ + {Name: "a", Type: v1.ManifestTypeKustomize, Path: "./a"}, + }, + }, + } + + cp1 := original + r.ConvertManifestPathFromCwd("/base", &cp1) + + cp2 := original + r.ConvertManifestPathFromCwd("/base", &cp2) + + if got, want := cp2.Spec.Manifests[0].Path, "/base/a"; got != want { + t.Errorf("second runner call observed mutated input: got %q, want %q (would be /base/base/a before the fix)", got, want) + } +} From d53b5b4a7ff8e8b18f6d59ad9d3c593acb74e1c3 Mon Sep 17 00:00:00 2001 From: drumato Date: Fri, 15 May 2026 19:39:58 +0900 Subject: [PATCH 07/13] fix(op,runner): propagate ctx to op and git subprocesses CommandClient's three op invocations accepted ctx but called exec.Command, and getGitCommitHash had no ctx at all. Both would ignore tazuna's cancellation: Ctrl-C left op processes orphaned and the git rev-parse could not be timed out. Switch to exec.CommandContext and thread ctx through getGitCommitHash and its StateSync caller. Closes #27 --- pkg/op/command_client.go | 6 +++--- pkg/runner/state_sync.go | 6 +++--- pkg/runner/state_sync_test.go | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pkg/op/command_client.go b/pkg/op/command_client.go index 9dc33d7..fe2369a 100644 --- a/pkg/op/command_client.go +++ b/pkg/op/command_client.go @@ -19,7 +19,7 @@ func (c *CommandClient) GetVault(ctx context.Context, vaultName string) (Vault, WithJSONFormat(). Build() - out, err := exec.Command(cmds[0], cmds[1:]...).Output() + out, err := exec.CommandContext(ctx, cmds[0], cmds[1:]...).Output() if err != nil { return Vault{}, err } @@ -45,7 +45,7 @@ func (c *CommandClient) GetVaultItem(ctx context.Context, vaultName string, item WithJSONFormat(). Build() - out, err := exec.Command(cmds[0], cmds[1:]...).Output() + out, err := exec.CommandContext(ctx, cmds[0], cmds[1:]...).Output() if err != nil { return Item{}, err } @@ -69,7 +69,7 @@ func (c *CommandClient) ListVaultItems(ctx context.Context, vaultName string) ([ WithJSONFormat(). Build() - out, err := exec.Command(cmds[0], cmds[1:]...).Output() + out, err := exec.CommandContext(ctx, cmds[0], cmds[1:]...).Output() if err != nil { return nil, err } diff --git a/pkg/runner/state_sync.go b/pkg/runner/state_sync.go index 5bad015..0414eed 100644 --- a/pkg/runner/state_sync.go +++ b/pkg/runner/state_sync.go @@ -53,7 +53,7 @@ func (t *TazunaRunner) StateSync( testPlugins := setupTestPlugins(t.k8sClient) store := state.NewConfigMapStateStore(t.k8sClient) autoDelete := strings.ToLower(os.Getenv("TAZUNA_STATE_SYNC_DELETE")) == "true" - gitCommit := getGitCommitHash() + gitCommit := getGitCommitHash(ctx) // atomicモード時はステート保存を全manifest処理完了後にまとめて行う pendingSaves := make(map[string]*state.StateData) @@ -257,8 +257,8 @@ func buildUnstructuredFromStateKey(keyStr string) (*unstructured.Unstructured, e } // getGitCommitHash は現在のgit commit hashを取得する -func getGitCommitHash() string { - out, err := exec.Command("git", "rev-parse", "HEAD").Output() +func getGitCommitHash(ctx context.Context) string { + out, err := exec.CommandContext(ctx, "git", "rev-parse", "HEAD").Output() if err != nil { return "" } diff --git a/pkg/runner/state_sync_test.go b/pkg/runner/state_sync_test.go index d9d9ed2..8823ff9 100644 --- a/pkg/runner/state_sync_test.go +++ b/pkg/runner/state_sync_test.go @@ -1,6 +1,7 @@ package runner import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -51,7 +52,7 @@ func TestBuildUnstructuredFromStateKey_InvalidKey(t *testing.T) { func TestGetGitCommitHash(t *testing.T) { t.Parallel() - hash := getGitCommitHash() + hash := getGitCommitHash(context.Background()) // gitリポジトリ内で実行されている場合、40文字のhex文字列が返る if hash != "" { assert.Len(t, hash, 40) From 77e50964dc387f24849481314a50b022ffd46130 Mon Sep 17 00:00:00 2001 From: drumato Date: Sat, 16 May 2026 09:34:10 +0900 Subject: [PATCH 08/13] feat(op): allowlist-validate vault/item identifiers before op invocation Identifiers passed to the 1Password CLI now pass through ValidateIdentifier, which restricts them to `[A-Za-z0-9_.\- ]+`. This turns unusual inputs (control characters, shell metacharacters, etc.) into an early, recognisable error instead of an opaque failure from op. Closes #36 --- pkg/op/command_client.go | 14 +++++++++ pkg/op/validate.go | 25 ++++++++++++++++ pkg/op/validate_test.go | 42 +++++++++++++++++++++++++++ pkg/runner/secret_to_genesissecret.go | 6 ++++ 4 files changed, 87 insertions(+) create mode 100644 pkg/op/validate.go create mode 100644 pkg/op/validate_test.go diff --git a/pkg/op/command_client.go b/pkg/op/command_client.go index fe2369a..f8a2c57 100644 --- a/pkg/op/command_client.go +++ b/pkg/op/command_client.go @@ -4,12 +4,17 @@ import ( "context" "encoding/json" "os/exec" + + "github.com/cockroachdb/errors" ) type CommandClient struct{} // GetVault implements Client. func (c *CommandClient) GetVault(ctx context.Context, vaultName string) (Vault, error) { + if err := ValidateIdentifier("vault", vaultName); err != nil { + return Vault{}, errors.WithStack(err) + } cmds := NewCommandBuilder().WithVault( NewVaultCommandBuilder(). WithGet( @@ -34,6 +39,12 @@ func (c *CommandClient) GetVault(ctx context.Context, vaultName string) (Vault, // GetVaultItem implements Client. func (c *CommandClient) GetVaultItem(ctx context.Context, vaultName string, itemName string) (Item, error) { + if err := ValidateIdentifier("vault", vaultName); err != nil { + return Item{}, errors.WithStack(err) + } + if err := ValidateIdentifier("item", itemName); err != nil { + return Item{}, errors.WithStack(err) + } cmds := NewCommandBuilder(). WithItem( NewItemCommandBuilder(). @@ -59,6 +70,9 @@ func (c *CommandClient) GetVaultItem(ctx context.Context, vaultName string, item } func (c *CommandClient) ListVaultItems(ctx context.Context, vaultName string) ([]Item, error) { + if err := ValidateIdentifier("vault", vaultName); err != nil { + return nil, errors.WithStack(err) + } cmds := NewCommandBuilder(). WithItem( NewItemCommandBuilder(). diff --git a/pkg/op/validate.go b/pkg/op/validate.go new file mode 100644 index 0000000..a66c58e --- /dev/null +++ b/pkg/op/validate.go @@ -0,0 +1,25 @@ +package op + +import ( + "regexp" + + "github.com/cockroachdb/errors" +) + +// identifierPattern restricts vault / item identifiers to characters that the +// 1Password CLI handles unambiguously. Values outside this set tend to produce +// opaque `op` errors, so we reject them at the call site instead. +var identifierPattern = regexp.MustCompile(`^[A-Za-z0-9_.\- ]+$`) + +// ValidateIdentifier checks that name is a non-empty identifier that matches +// the allowlist. kind is included in the error so callers can tell which +// argument was rejected (e.g. "vault", "item"). +func ValidateIdentifier(kind, name string) error { + if name == "" { + return errors.Newf("%s identifier must not be empty", kind) + } + if !identifierPattern.MatchString(name) { + return errors.Newf("%s identifier %q contains characters outside the allowed set %s", kind, name, identifierPattern.String()) + } + return nil +} diff --git a/pkg/op/validate_test.go b/pkg/op/validate_test.go new file mode 100644 index 0000000..3cf4d9d --- /dev/null +++ b/pkg/op/validate_test.go @@ -0,0 +1,42 @@ +package op_test + +import ( + "testing" + + "github.com/pepabo/tazuna/pkg/op" +) + +func TestValidateIdentifier(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + kind string + value string + wantErr bool + }{ + {name: "alnum", kind: "vault", value: "MyVault01", wantErr: false}, + {name: "with hyphen and underscore", kind: "vault", value: "my-vault_01", wantErr: false}, + {name: "with dot", kind: "item", value: "tls.key.v1", wantErr: false}, + {name: "with spaces", kind: "item", value: "Production API Key", wantErr: false}, + {name: "empty", kind: "vault", value: "", wantErr: true}, + {name: "newline", kind: "item", value: "name\nname", wantErr: true}, + {name: "tab", kind: "item", value: "name\tname", wantErr: true}, + {name: "slash", kind: "vault", value: "a/b", wantErr: true}, + {name: "shell metachar", kind: "vault", value: "v$(whoami)", wantErr: true}, + {name: "non-ascii", kind: "item", value: "ボールト", wantErr: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := op.ValidateIdentifier(tc.kind, tc.value) + if tc.wantErr && err == nil { + t.Fatalf("ValidateIdentifier(%q, %q) = nil, want error", tc.kind, tc.value) + } + if !tc.wantErr && err != nil { + t.Fatalf("ValidateIdentifier(%q, %q) = %v, want nil", tc.kind, tc.value, err) + } + }) + } +} diff --git a/pkg/runner/secret_to_genesissecret.go b/pkg/runner/secret_to_genesissecret.go index cc8c10f..cf83709 100644 --- a/pkg/runner/secret_to_genesissecret.go +++ b/pkg/runner/secret_to_genesissecret.go @@ -292,8 +292,14 @@ func itemCreateCommandsFromItems( vaultName string, dryRun bool, ) ([]*exec.Cmd, error) { + if err := op.ValidateIdentifier("vault", vaultName); err != nil { + return nil, errors.WithStack(err) + } commands := []*exec.Cmd{} for _, item := range items { + if err := op.ValidateIdentifier("item", item.Title); err != nil { + return nil, errors.WithStack(err) + } category := op.VaultItemCategoryAPICredential itemCreateCommand := op.NewCommandBuilder(). WithItem( From 3bd307d7f64ff172f1902d8ebc127b999821b962 Mon Sep 17 00:00:00 2001 From: drumato Date: Sat, 16 May 2026 09:39:23 +0900 Subject: [PATCH 09/13] refactor(cmd): extract boilerplate to cmd/internal/cliutil Each subcommand was re-parsing --log-level, building a slog logger, opening tazuna.yaml with a defer-close dance, and standing up a controller-runtime client by hand. The duplication made adding a subcommand noisy and meant cross-cutting changes (e.g. how Close errors join the return value) had to be repeated everywhere. Introduce cmd/internal/cliutil with ParseLogLevel, NewLogger, LoadTazunaYAML, and NewK8sClient, and route every subcommand's RunE through them so each body focuses on its own orchestration. Closes #29 --- cmd/apply.go | 52 ++++--------------- cmd/build.go | 52 ++++--------------- cmd/check.go | 44 ++++------------ cmd/destroy.go | 51 ++++--------------- cmd/internal/cliutil/cliutil.go | 75 ++++++++++++++++++++++++++++ cmd/internal/cliutil/cliutil_test.go | 64 ++++++++++++++++++++++++ cmd/secret_to_genesissecret.go | 28 ++--------- cmd/state_diff.go | 49 ++++-------------- cmd/state_list.go | 49 ++++-------------- cmd/state_sync.go | 49 ++++-------------- cmd/tags.go | 25 +++------- 11 files changed, 218 insertions(+), 320 deletions(-) create mode 100644 cmd/internal/cliutil/cliutil.go create mode 100644 cmd/internal/cliutil/cliutil_test.go diff --git a/cmd/apply.go b/cmd/apply.go index 8ebddd1..4aadcd2 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -1,21 +1,15 @@ package cmd import ( - "log/slog" - "os" "path/filepath" - "strings" "github.com/cockroachdb/errors" - v1 "github.com/pepabo/tazuna/api/v1" + "github.com/pepabo/tazuna/cmd/internal/cliutil" tazunacontext "github.com/pepabo/tazuna/pkg/context" "github.com/pepabo/tazuna/pkg/op" "github.com/pepabo/tazuna/pkg/runner" "github.com/pepabo/tazuna/pkg/validator" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" ) // applyCmd represents the apply command @@ -35,41 +29,25 @@ Examples: tazuna apply -f tazuna.yaml tazuna apply -f tazuna.yaml --tags web,batch tazuna apply -f tazuna.yaml --log-level debug`, - RunE: func(cmd *cobra.Command, args []string) (err error) { + RunE: func(cmd *cobra.Command, args []string) error { path, err := cmd.Flags().GetString("file-path") if err != nil { return errors.WithStack(err) } - logLevelS, err := cmd.Flags().GetString("log-level") + logger, err := cliutil.NewLogger(cmd) if err != nil { - return errors.WithStack(err) - } - var logLevel slog.Level - switch strings.ToLower(logLevelS) { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - default: - logLevel = slog.LevelInfo + return err } - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) tags := []string{} if v, err := cmd.Flags().GetStringSlice("tags"); err == nil { tags = v } - restConfig, err := ctrl.GetConfig() + k8sClient, err := cliutil.NewK8sClient() if err != nil { - return errors.WithStack(err) - } - k8sClient, err := client.New(restConfig, client.Options{}) - if err != nil { - return errors.WithStack(err) + return err } orasOpts, err := buildORASPullOptions(cmd) @@ -79,23 +57,13 @@ Examples: r := runner.NewTazunaRunner(logger, k8sClient, &op.CommandClient{}, runner.WithTags(tags), runner.WithORASPullOptions(orasOpts)) - f, err := os.Open(path) + tazuna, err := cliutil.LoadTazunaYAML(path) if err != nil { - return errors.WithStack(err) - } - defer func() { - if cerr := f.Close(); cerr != nil { - err = errors.Join(err, errors.WithStack(cerr)) - } - }() - - tazuna := v1.Tazuna{} - if err := yaml.NewDecoder(f).Decode(&tazuna); err != nil { - return errors.WithStack(err) + return err } // tazuna.yamlのvalidation(include展開前のバリデーション) - if err := validator.ValidateTazunaWithBasePath(&tazuna, filepath.Dir(path)); err != nil { + if err := validator.ValidateTazunaWithBasePath(tazuna, filepath.Dir(path)); err != nil { return errors.Wrapf(err, "validation failed for tazuna.yaml at %s", path) } @@ -105,7 +73,7 @@ Examples: } } - if err := r.Apply(cmd.Context(), tazuna, path); err != nil { + if err := r.Apply(cmd.Context(), *tazuna, path); err != nil { return errors.WithStack(err) } return nil diff --git a/cmd/build.go b/cmd/build.go index 46d0ed6..0274181 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -2,20 +2,14 @@ package cmd import ( "fmt" - "log/slog" - "os" "path/filepath" - "strings" "github.com/cockroachdb/errors" - v1 "github.com/pepabo/tazuna/api/v1" + "github.com/pepabo/tazuna/cmd/internal/cliutil" "github.com/pepabo/tazuna/pkg/op" "github.com/pepabo/tazuna/pkg/runner" "github.com/pepabo/tazuna/pkg/validator" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" ) // buildCmd represents the build command @@ -30,41 +24,25 @@ Use the --tags flag to target only manifests with the specified tags. Examples: tazuna build -f tazuna.yaml tazuna build -f tazuna.yaml --tags web`, - RunE: func(cmd *cobra.Command, args []string) (err error) { + RunE: func(cmd *cobra.Command, args []string) error { path, err := cmd.Flags().GetString("file-path") if err != nil { return err } - logLevelS, err := cmd.Flags().GetString("log-level") + logger, err := cliutil.NewLogger(cmd) if err != nil { - return errors.WithStack(err) - } - var logLevel slog.Level - switch strings.ToLower(logLevelS) { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - default: - logLevel = slog.LevelInfo + return err } - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) tags := []string{} if v, err := cmd.Flags().GetStringSlice("tags"); err == nil { tags = v } - restConfig, err := ctrl.GetConfig() + k8sClient, err := cliutil.NewK8sClient() if err != nil { - return errors.WithStack(err) - } - k8sClient, err := client.New(restConfig, client.Options{}) - if err != nil { - return errors.WithStack(err) + return err } orasOpts, err := buildORASPullOptions(cmd) if err != nil { @@ -72,27 +50,17 @@ Examples: } r := runner.NewTazunaRunner(logger, k8sClient, &op.CommandClient{}, runner.WithTags(tags), runner.WithORASPullOptions(orasOpts)) - f, err := os.Open(path) + tazuna, err := cliutil.LoadTazunaYAML(path) if err != nil { - return errors.WithStack(err) - } - defer func() { - if cerr := f.Close(); cerr != nil { - err = errors.Join(err, errors.WithStack(cerr)) - } - }() - - tazuna := v1.Tazuna{} - if err := yaml.NewDecoder(f).Decode(&tazuna); err != nil { - return errors.WithStack(err) + return err } // tazuna.yamlのvalidation(include展開前のバリデーション) - if err := validator.ValidateTazunaWithBasePath(&tazuna, filepath.Dir(path)); err != nil { + if err := validator.ValidateTazunaWithBasePath(tazuna, filepath.Dir(path)); err != nil { return errors.Wrapf(err, "validation failed for tazuna.yaml at %s", path) } - out, err := r.Build(cmd.Context(), tazuna, path) + out, err := r.Build(cmd.Context(), *tazuna, path) if err != nil { return errors.WithStack(err) } diff --git a/cmd/check.go b/cmd/check.go index d87d05a..8308a04 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -2,13 +2,11 @@ package cmd import ( "fmt" - "log/slog" "os" "path/filepath" - "strings" "github.com/cockroachdb/errors" - v1 "github.com/pepabo/tazuna/api/v1" + "github.com/pepabo/tazuna/cmd/internal/cliutil" "github.com/pepabo/tazuna/pkg/runner" "github.com/pepabo/tazuna/pkg/validator" "github.com/spf13/cobra" @@ -32,7 +30,7 @@ tazuna.yaml is written back. Examples: tazuna check -f tazuna.yaml tazuna check -f tazuna.yaml --fix`, - RunE: func(cmd *cobra.Command, args []string) (err error) { + RunE: func(cmd *cobra.Command, args []string) error { path, err := cmd.Flags().GetString("file-path") if err != nil { return errors.WithStack(err) @@ -42,19 +40,9 @@ Examples: return errors.WithStack(err) } - f, err := os.Open(path) + tazuna, err := cliutil.LoadTazunaYAML(path) if err != nil { - return errors.WithStack(err) - } - defer func() { - if cerr := f.Close(); cerr != nil { - err = errors.Join(err, errors.WithStack(cerr)) - } - }() - - tazuna := v1.Tazuna{} - if err := yaml.NewDecoder(f).Decode(&tazuna); err != nil { - return errors.WithStack(err) + return err } absPath, err := filepath.Abs(path) @@ -62,34 +50,22 @@ Examples: return errors.WithStack(err) } - if err := validator.ValidateTazunaWithBasePath(&tazuna, filepath.Dir(absPath)); err != nil { + if err := validator.ValidateTazunaWithBasePath(tazuna, filepath.Dir(absPath)); err != nil { return errors.Wrapf(err, "validation failed for tazuna.yaml at %s", path) } - logLevelS, err := cmd.Flags().GetString("log-level") + logger, err := cliutil.NewLogger(cmd) if err != nil { - return errors.WithStack(err) - } - var logLevel slog.Level - switch strings.ToLower(logLevelS) { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - default: - logLevel = slog.LevelInfo + return err } - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) r := runner.NewTazunaRunner(logger, nil, nil) if fix { - if err := r.CheckAndFix(cmd.Context(), &tazuna, absPath); err != nil { + if err := r.CheckAndFix(cmd.Context(), tazuna, absPath); err != nil { return errors.Wrapf(err, "check --fix failed for tazuna.yaml at %s", path) } - out, err := yaml.Marshal(&tazuna) + out, err := yaml.Marshal(tazuna) if err != nil { return errors.WithStack(err) } @@ -101,7 +77,7 @@ Examples: return nil } - if err := r.Check(cmd.Context(), &tazuna, absPath); err != nil { + if err := r.Check(cmd.Context(), tazuna, absPath); err != nil { return errors.Wrapf(err, "check failed for tazuna.yaml at %s", path) } diff --git a/cmd/destroy.go b/cmd/destroy.go index 2b15154..266804f 100644 --- a/cmd/destroy.go +++ b/cmd/destroy.go @@ -1,23 +1,18 @@ package cmd import ( - "log/slog" "os" "path/filepath" "strconv" - "strings" "github.com/cockroachdb/errors" - v1 "github.com/pepabo/tazuna/api/v1" + "github.com/pepabo/tazuna/cmd/internal/cliutil" tazunacontext "github.com/pepabo/tazuna/pkg/context" "github.com/pepabo/tazuna/pkg/op" "github.com/pepabo/tazuna/pkg/prompt" "github.com/pepabo/tazuna/pkg/runner" "github.com/pepabo/tazuna/pkg/validator" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" ) // destroyCmd represents the build command @@ -35,36 +30,20 @@ Use the --tags flag to target only manifests with the specified tags. Examples: TAZUNA_DESTROY_EXECUTABLE=true tazuna destroy -f tazuna.yaml TAZUNA_DESTROY_EXECUTABLE=true tazuna destroy -f tazuna.yaml --force`, - RunE: func(cmd *cobra.Command, args []string) (err error) { + RunE: func(cmd *cobra.Command, args []string) error { path, err := cmd.Flags().GetString("file-path") if err != nil { return errors.WithStack(err) } - logLevelS, err := cmd.Flags().GetString("log-level") + logger, err := cliutil.NewLogger(cmd) if err != nil { - return errors.WithStack(err) - } - var logLevel slog.Level - switch strings.ToLower(logLevelS) { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - default: - logLevel = slog.LevelInfo + return err } - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) - restConfig, err := ctrl.GetConfig() + k8sClient, err := cliutil.NewK8sClient() if err != nil { - return errors.WithStack(err) - } - k8sClient, err := client.New(restConfig, client.Options{}) - if err != nil { - return errors.WithStack(err) + return err } tags := []string{} if v, err := cmd.Flags().GetStringSlice("tags"); err == nil { @@ -76,23 +55,13 @@ Examples: } r := runner.NewTazunaRunner(logger, k8sClient, &op.CommandClient{}, runner.WithTags(tags), runner.WithORASPullOptions(orasOpts)) - f, err := os.Open(path) + tazuna, err := cliutil.LoadTazunaYAML(path) if err != nil { - return errors.WithStack(err) - } - defer func() { - if cerr := f.Close(); cerr != nil { - err = errors.Join(err, errors.WithStack(cerr)) - } - }() - - tazuna := v1.Tazuna{} - if err := yaml.NewDecoder(f).Decode(&tazuna); err != nil { - return errors.WithStack(err) + return err } // tazuna.yamlのvalidation(include展開前のバリデーション) - if err := validator.ValidateTazunaWithBasePath(&tazuna, filepath.Dir(path)); err != nil { + if err := validator.ValidateTazunaWithBasePath(tazuna, filepath.Dir(path)); err != nil { return errors.Wrapf(err, "validation failed for tazuna.yaml at %s", path) } @@ -118,7 +87,7 @@ Examples: return nil } - if err := r.Destroy(cmd.Context(), tazuna, path); err != nil { + if err := r.Destroy(cmd.Context(), *tazuna, path); err != nil { return errors.WithStack(err) } return nil diff --git a/cmd/internal/cliutil/cliutil.go b/cmd/internal/cliutil/cliutil.go new file mode 100644 index 0000000..4327dcf --- /dev/null +++ b/cmd/internal/cliutil/cliutil.go @@ -0,0 +1,75 @@ +// Package cliutil collects boilerplate shared across cobra commands in cmd/. +// Each helper here is deliberately small and orthogonal so that RunE bodies can +// focus on command-specific orchestration rather than plumbing. +package cliutil + +import ( + "log/slog" + "os" + "strings" + + "github.com/cockroachdb/errors" + v1 "github.com/pepabo/tazuna/api/v1" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ParseLogLevel maps the textual log level used by Tazuna's --log-level flag to +// a slog.Level. Unknown values fall back to slog.LevelInfo. +func ParseLogLevel(s string) slog.Level { + switch strings.ToLower(s) { + case "debug": + return slog.LevelDebug + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +// NewLogger reads the persistent --log-level flag and returns a slog.Logger +// that writes to stderr. +func NewLogger(cmd *cobra.Command) (*slog.Logger, error) { + logLevelS, err := cmd.Flags().GetString("log-level") + if err != nil { + return nil, errors.WithStack(err) + } + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: ParseLogLevel(logLevelS)})), nil +} + +// LoadTazunaYAML opens path, decodes it as a v1.Tazuna document, and closes +// the file. Decoding and close errors are joined so neither is dropped. +func LoadTazunaYAML(path string) (_ *v1.Tazuna, err error) { + f, err := os.Open(path) + if err != nil { + return nil, errors.WithStack(err) + } + defer func() { + if cerr := f.Close(); cerr != nil { + err = errors.Join(err, errors.WithStack(cerr)) + } + }() + + tazuna := v1.Tazuna{} + if err := yaml.NewDecoder(f).Decode(&tazuna); err != nil { + return nil, errors.WithStack(err) + } + return &tazuna, nil +} + +// NewK8sClient builds a controller-runtime client from the ambient kubeconfig. +func NewK8sClient() (client.Client, error) { + restConfig, err := ctrl.GetConfig() + if err != nil { + return nil, errors.WithStack(err) + } + c, err := client.New(restConfig, client.Options{}) + if err != nil { + return nil, errors.WithStack(err) + } + return c, nil +} diff --git a/cmd/internal/cliutil/cliutil_test.go b/cmd/internal/cliutil/cliutil_test.go new file mode 100644 index 0000000..aa233dd --- /dev/null +++ b/cmd/internal/cliutil/cliutil_test.go @@ -0,0 +1,64 @@ +package cliutil_test + +import ( + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/pepabo/tazuna/cmd/internal/cliutil" +) + +func TestParseLogLevel(t *testing.T) { + t.Parallel() + cases := map[string]slog.Level{ + "debug": slog.LevelDebug, + "DEBUG": slog.LevelDebug, + "warn": slog.LevelWarn, + "error": slog.LevelError, + "info": slog.LevelInfo, + "": slog.LevelInfo, + "unknown": slog.LevelInfo, + } + for in, want := range cases { + t.Run(in, func(t *testing.T) { + t.Parallel() + if got := cliutil.ParseLogLevel(in); got != want { + t.Errorf("ParseLogLevel(%q) = %v, want %v", in, got, want) + } + }) + } +} + +func TestLoadTazunaYAML(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "tazuna.yaml") + yaml := `apiVersion: tazuna.pepabo.com/v1 +kind: Tazuna +spec: + manifests: [] +` + if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil { + t.Fatalf("write tmp file: %v", err) + } + + got, err := cliutil.LoadTazunaYAML(path) + if err != nil { + t.Fatalf("LoadTazunaYAML returned error: %v", err) + } + if got == nil { + t.Fatal("LoadTazunaYAML returned nil Tazuna") + } + if got.APIVersion != "tazuna.pepabo.com/v1" || got.Kind != "Tazuna" { + t.Errorf("decoded Tazuna mismatch: %+v", got) + } +} + +func TestLoadTazunaYAML_MissingFile(t *testing.T) { + t.Parallel() + if _, err := cliutil.LoadTazunaYAML(filepath.Join(t.TempDir(), "does-not-exist.yaml")); err == nil { + t.Fatal("expected error for missing file, got nil") + } +} diff --git a/cmd/secret_to_genesissecret.go b/cmd/secret_to_genesissecret.go index 2bc529d..3b81638 100644 --- a/cmd/secret_to_genesissecret.go +++ b/cmd/secret_to_genesissecret.go @@ -3,12 +3,10 @@ package cmd import ( "context" "encoding/json" - "log/slog" - "os" "os/exec" - "strings" "github.com/cockroachdb/errors" + "github.com/pepabo/tazuna/cmd/internal/cliutil" "github.com/pepabo/tazuna/pkg/op" "github.com/pepabo/tazuna/pkg/runner" "github.com/spf13/cobra" @@ -22,30 +20,14 @@ var secretToGenesisSecretCmd = &cobra.Command{ Use: "secret-to-genesissecret", Short: "Save existing cluster Secrets to 1Password and generate the corresponding GenesisSecret", RunE: func(cmd *cobra.Command, args []string) error { - logLevelS, err := cmd.Flags().GetString("log-level") + logger, err := cliutil.NewLogger(cmd) if err != nil { - return errors.WithStack(err) - } - var logLevel slog.Level - switch strings.ToLower(logLevelS) { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - default: - logLevel = slog.LevelInfo + return err } - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) - restConfig, err := ctrl.GetConfig() - if err != nil { - return errors.WithStack(err) - } - k8sClient, err := client.New(restConfig, client.Options{}) + k8sClient, err := cliutil.NewK8sClient() if err != nil { - return errors.WithStack(err) + return err } opHost, err := cmd.Flags().GetString("op-host") diff --git a/cmd/state_diff.go b/cmd/state_diff.go index 360f32c..1ac44a4 100644 --- a/cmd/state_diff.go +++ b/cmd/state_diff.go @@ -1,17 +1,12 @@ package cmd import ( - "log/slog" "os" - "strings" "github.com/cockroachdb/errors" - v1 "github.com/pepabo/tazuna/api/v1" + "github.com/pepabo/tazuna/cmd/internal/cliutil" "github.com/pepabo/tazuna/pkg/runner" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" ) var stateDiffCmd = &cobra.Command{ @@ -25,56 +20,30 @@ GenesisSecret resources are always shown as "always-sync" since they must be syn Examples: tazuna state diff tazuna state diff -f tazuna.yaml`, - RunE: func(cmd *cobra.Command, args []string) (err error) { + RunE: func(cmd *cobra.Command, args []string) error { path, err := cmd.Flags().GetString("file-path") if err != nil { return errors.WithStack(err) } - logLevelS, err := cmd.Flags().GetString("log-level") + logger, err := cliutil.NewLogger(cmd) if err != nil { - return errors.WithStack(err) - } - var logLevel slog.Level - switch strings.ToLower(logLevelS) { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - default: - logLevel = slog.LevelInfo + return err } - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) - restConfig, err := ctrl.GetConfig() - if err != nil { - return errors.WithStack(err) - } - k8sClient, err := client.New(restConfig, client.Options{}) + k8sClient, err := cliutil.NewK8sClient() if err != nil { - return errors.WithStack(err) + return err } r := runner.NewTazunaRunner(logger, k8sClient, nil) - f, err := os.Open(path) + tazuna, err := cliutil.LoadTazunaYAML(path) if err != nil { - return errors.WithStack(err) - } - defer func() { - if cerr := f.Close(); cerr != nil { - err = errors.Join(err, errors.WithStack(cerr)) - } - }() - - tazuna := v1.Tazuna{} - if err := yaml.NewDecoder(f).Decode(&tazuna); err != nil { - return errors.WithStack(err) + return err } - if err := r.StateDiff(cmd.Context(), tazuna, path, os.Stdout); err != nil { + if err := r.StateDiff(cmd.Context(), *tazuna, path, os.Stdout); err != nil { return errors.WithStack(err) } return nil diff --git a/cmd/state_list.go b/cmd/state_list.go index 14871b0..daa7df6 100644 --- a/cmd/state_list.go +++ b/cmd/state_list.go @@ -1,17 +1,12 @@ package cmd import ( - "log/slog" "os" - "strings" "github.com/cockroachdb/errors" - v1 "github.com/pepabo/tazuna/api/v1" + "github.com/pepabo/tazuna/cmd/internal/cliutil" "github.com/pepabo/tazuna/pkg/runner" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" ) var stateListCmd = &cobra.Command{ @@ -26,56 +21,30 @@ namespace/name, and content hash of each resource are displayed. Examples: tazuna state list tazuna state list -f tazuna.yaml`, - RunE: func(cmd *cobra.Command, args []string) (err error) { + RunE: func(cmd *cobra.Command, args []string) error { path, err := cmd.Flags().GetString("file-path") if err != nil { return errors.WithStack(err) } - logLevelS, err := cmd.Flags().GetString("log-level") + logger, err := cliutil.NewLogger(cmd) if err != nil { - return errors.WithStack(err) - } - var logLevel slog.Level - switch strings.ToLower(logLevelS) { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - default: - logLevel = slog.LevelInfo + return err } - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) - restConfig, err := ctrl.GetConfig() - if err != nil { - return errors.WithStack(err) - } - k8sClient, err := client.New(restConfig, client.Options{}) + k8sClient, err := cliutil.NewK8sClient() if err != nil { - return errors.WithStack(err) + return err } r := runner.NewTazunaRunner(logger, k8sClient, nil) - f, err := os.Open(path) + tazuna, err := cliutil.LoadTazunaYAML(path) if err != nil { - return errors.WithStack(err) - } - defer func() { - if cerr := f.Close(); cerr != nil { - err = errors.Join(err, errors.WithStack(cerr)) - } - }() - - tazuna := v1.Tazuna{} - if err := yaml.NewDecoder(f).Decode(&tazuna); err != nil { - return errors.WithStack(err) + return err } - if err := r.StateList(cmd.Context(), tazuna, path, os.Stdout); err != nil { + if err := r.StateList(cmd.Context(), *tazuna, path, os.Stdout); err != nil { return errors.WithStack(err) } return nil diff --git a/cmd/state_sync.go b/cmd/state_sync.go index 40f132f..ef6143a 100644 --- a/cmd/state_sync.go +++ b/cmd/state_sync.go @@ -1,17 +1,12 @@ package cmd import ( - "log/slog" "os" - "strings" "github.com/cockroachdb/errors" - v1 "github.com/pepabo/tazuna/api/v1" + "github.com/pepabo/tazuna/cmd/internal/cliutil" "github.com/pepabo/tazuna/pkg/runner" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" ) var stateSyncCmd = &cobra.Command{ @@ -33,53 +28,27 @@ Examples: tazuna state sync -f tazuna.yaml tazuna state sync --atomic TAZUNA_STATE_SYNC_DELETE=true tazuna state sync`, - RunE: func(cmd *cobra.Command, args []string) (err error) { + RunE: func(cmd *cobra.Command, args []string) error { path, err := cmd.Flags().GetString("file-path") if err != nil { return errors.WithStack(err) } - logLevelS, err := cmd.Flags().GetString("log-level") + logger, err := cliutil.NewLogger(cmd) if err != nil { - return errors.WithStack(err) - } - var logLevel slog.Level - switch strings.ToLower(logLevelS) { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - default: - logLevel = slog.LevelInfo + return err } - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) - restConfig, err := ctrl.GetConfig() - if err != nil { - return errors.WithStack(err) - } - k8sClient, err := client.New(restConfig, client.Options{}) + k8sClient, err := cliutil.NewK8sClient() if err != nil { - return errors.WithStack(err) + return err } r := runner.NewTazunaRunner(logger, k8sClient, nil) - f, err := os.Open(path) + tazuna, err := cliutil.LoadTazunaYAML(path) if err != nil { - return errors.WithStack(err) - } - defer func() { - if cerr := f.Close(); cerr != nil { - err = errors.Join(err, errors.WithStack(cerr)) - } - }() - - tazuna := v1.Tazuna{} - if err := yaml.NewDecoder(f).Decode(&tazuna); err != nil { - return errors.WithStack(err) + return err } atomic, err := cmd.Flags().GetBool("atomic") @@ -91,7 +60,7 @@ Examples: Atomic: atomic, } - if err := r.StateSync(cmd.Context(), tazuna, path, os.Stdout, opts); err != nil { + if err := r.StateSync(cmd.Context(), *tazuna, path, os.Stdout, opts); err != nil { return errors.WithStack(err) } return nil diff --git a/cmd/tags.go b/cmd/tags.go index 9adb5c0..fcb29c6 100644 --- a/cmd/tags.go +++ b/cmd/tags.go @@ -4,14 +4,12 @@ import ( "fmt" "io" "log/slog" - "os" "github.com/cockroachdb/errors" - v1 "github.com/pepabo/tazuna/api/v1" + "github.com/pepabo/tazuna/cmd/internal/cliutil" "github.com/pepabo/tazuna/pkg/runner" "github.com/pepabo/tazuna/pkg/validator" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" ) var tagsCmd = &cobra.Command{ @@ -21,34 +19,25 @@ var tagsCmd = &cobra.Command{ Examples: tazuna tags -f tazuna.yaml`, - RunE: func(cmd *cobra.Command, args []string) (err error) { + RunE: func(cmd *cobra.Command, args []string) error { path, err := cmd.Flags().GetString("file-path") if err != nil { return errors.WithStack(err) } - f, err := os.Open(path) - if err != nil { - return errors.WithStack(err) - } - defer func() { - if cerr := f.Close(); cerr != nil { - err = errors.Join(err, errors.WithStack(cerr)) - } - }() - tazuna := v1.Tazuna{} - if err := yaml.NewDecoder(f).Decode(&tazuna); err != nil { - return errors.WithStack(err) + tazuna, err := cliutil.LoadTazunaYAML(path) + if err != nil { + return err } - if err := validator.ValidateTazuna(&tazuna); err != nil { + if err := validator.ValidateTazuna(tazuna); err != nil { return errors.Wrapf(err, "validation failed for tazuna.yaml at %s", path) } logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})) r := runner.NewTazunaRunner(logger, nil, nil) - tags, err := r.ListTags(cmd.Context(), &tazuna, path) + tags, err := r.ListTags(cmd.Context(), tazuna, path) if err != nil { return errors.Wrapf(err, "failed to list tags for tazuna.yaml at %s", path) } From 27c7ee889012a8d13fda126823c47c7212032ba6 Mon Sep 17 00:00:00 2001 From: drumato Date: Sat, 16 May 2026 09:43:47 +0900 Subject: [PATCH 10/13] test(cmd): add minimal unit tests for subcommand wiring and RunE cmd/ only had oras_flags_test.go, so RunE bodies and flag wiring (defaults, required, exclusion, subcommand registration) were untested. Adds three small files modelled on oras_flags_test.go: - flags_test.go covers root persistent flags, per-subcommand flag defaults, the required marker on --op-host, and subcommand registration on rootCmd / stateCmd. - check_test.go drives checkCmd.RunE end-to-end with a temp tazuna.yaml, covering success, missing file, invalid YAML, validation failure, and --fix writing the file back. - tags_test.go drives tagsCmd.RunE the same way for success / validation failure / missing file. Closes #37 --- cmd/check_test.go | 110 ++++++++++++++++++++++++++++++++ cmd/flags_test.go | 158 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/tags_test.go | 77 ++++++++++++++++++++++ 3 files changed, 345 insertions(+) create mode 100644 cmd/check_test.go create mode 100644 cmd/flags_test.go create mode 100644 cmd/tags_test.go diff --git a/cmd/check_test.go b/cmd/check_test.go new file mode 100644 index 0000000..f1ce386 --- /dev/null +++ b/cmd/check_test.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// resetCheckFlags clears state that ParseFlags leaves on the package-level +// command tree, so successive tests do not see stale values. +func resetCheckFlags(t *testing.T) { + t.Helper() + t.Cleanup(func() { + _ = checkCmd.Flags().Set("fix", "false") + _ = rootCmd.PersistentFlags().Set("file-path", "tazuna.yaml") + _ = rootCmd.PersistentFlags().Set("log-level", "info") + }) +} + +func writeYAML(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "tazuna.yaml") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write tmp file: %v", err) + } + return path +} + +func runCheck(t *testing.T, args []string) error { + t.Helper() + resetCheckFlags(t) + checkCmd.SetOut(io.Discard) + checkCmd.SetErr(io.Discard) + if err := checkCmd.ParseFlags(args); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + checkCmd.SetContext(context.Background()) + return checkCmd.RunE(checkCmd, []string{}) +} + +func TestCheckCmd_Success(t *testing.T) { + path := writeYAML(t, `apiVersion: tazuna.pepabo.com/v1 +kind: Tazuna +spec: + manifests: + - name: kustomize-app + type: kustomize + path: ./kustomize +`) + if err := runCheck(t, []string{"-f", path}); err != nil { + t.Fatalf("expected nil error, got: %v", err) + } +} + +func TestCheckCmd_InvalidYAML(t *testing.T) { + path := writeYAML(t, "::: not yaml :::") + err := runCheck(t, []string{"-f", path}) + if err == nil { + t.Fatal("expected error for invalid YAML, got nil") + } +} + +func TestCheckCmd_MissingFile(t *testing.T) { + err := runCheck(t, []string{"-f", filepath.Join(t.TempDir(), "missing.yaml")}) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestCheckCmd_ValidationFails(t *testing.T) { + // manifest has no type / path → ValidateTazunaWithBasePath fails + path := writeYAML(t, `apiVersion: tazuna.pepabo.com/v1 +kind: Tazuna +spec: + manifests: + - name: bad +`) + err := runCheck(t, []string{"-f", path}) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "validation failed") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestCheckCmd_Fix(t *testing.T) { + // manifest without name → --fix assigns one and writes the file back + path := writeYAML(t, `apiVersion: tazuna.pepabo.com/v1 +kind: Tazuna +spec: + manifests: + - type: kustomize + path: ./kustomize +`) + if err := runCheck(t, []string{"-f", path, "--fix"}); err != nil { + t.Fatalf("--fix returned error: %v", err) + } + fixed, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if !strings.Contains(string(fixed), "name:") { + t.Errorf("expected a name to be assigned, got:\n%s", fixed) + } +} diff --git a/cmd/flags_test.go b/cmd/flags_test.go new file mode 100644 index 0000000..9d93a04 --- /dev/null +++ b/cmd/flags_test.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// flagSpec captures the expected shape of a flag on a subcommand: presence, +// default value, and whether it is registered as a persistent flag on the +// nearest ancestor (when persistent is true, the flag is looked up via +// InheritedFlags after AddCommand wiring). +type flagSpec struct { + name string + defaultVal string + persistent bool +} + +func assertFlag(t *testing.T, cmd *cobra.Command, spec flagSpec) { + t.Helper() + var f = cmd.Flags().Lookup(spec.name) + if f == nil && spec.persistent { + f = cmd.InheritedFlags().Lookup(spec.name) + } + if f == nil { + t.Fatalf("flag %q not registered on %s", spec.name, cmd.Use) + } + if f.DefValue != spec.defaultVal { + t.Errorf("flag %q on %s: default = %q, want %q", spec.name, cmd.Use, f.DefValue, spec.defaultVal) + } +} + +func TestRootPersistentFlags(t *testing.T) { + t.Parallel() + for _, spec := range []flagSpec{ + {name: "file-path", defaultVal: "tazuna.yaml"}, + {name: "log-level", defaultVal: "info"}, + } { + if f := rootCmd.PersistentFlags().Lookup(spec.name); f == nil { + t.Errorf("root persistent flag %q missing", spec.name) + } else if f.DefValue != spec.defaultVal { + t.Errorf("root persistent flag %q default = %q, want %q", spec.name, f.DefValue, spec.defaultVal) + } + } +} + +func TestSubcommandFlags(t *testing.T) { + t.Parallel() + + cases := []struct { + cmd *cobra.Command + flags []flagSpec + }{ + { + cmd: applyCmd, + flags: []flagSpec{ + {name: "tags", defaultVal: "[]"}, + {name: "no-cache", defaultVal: "false"}, + {name: "offline", defaultVal: "false"}, + }, + }, + { + cmd: destroyCmd, + flags: []flagSpec{ + {name: "force", defaultVal: "false"}, + {name: "tags", defaultVal: "[]"}, + {name: "no-cache", defaultVal: "false"}, + {name: "offline", defaultVal: "false"}, + }, + }, + { + cmd: buildCmd, + flags: []flagSpec{ + {name: "cluster-name", defaultVal: "kind-tazuna"}, + {name: "tags", defaultVal: "[]"}, + {name: "no-cache", defaultVal: "false"}, + {name: "offline", defaultVal: "false"}, + }, + }, + { + cmd: checkCmd, + flags: []flagSpec{ + {name: "fix", defaultVal: "false"}, + }, + }, + { + cmd: stateSyncCmd, + flags: []flagSpec{ + {name: "atomic", defaultVal: "false"}, + }, + }, + { + cmd: secretToGenesisSecretCmd, + flags: []flagSpec{ + {name: "label-selector", defaultVal: ""}, + {name: "name-regex", defaultVal: ""}, + {name: "vault", defaultVal: ""}, + {name: "namespace", defaultVal: "default"}, + {name: "dry-run", defaultVal: "false"}, + {name: "dump-dir", defaultVal: "."}, + {name: "note", defaultVal: ""}, + {name: "op-host", defaultVal: ""}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.cmd.Use, func(t *testing.T) { + t.Parallel() + for _, spec := range tc.flags { + assertFlag(t, tc.cmd, spec) + } + }) + } +} + +func TestSecretToGenesisSecretRequiresOpHost(t *testing.T) { + t.Parallel() + annotations := secretToGenesisSecretCmd.Flag("op-host").Annotations + required, ok := annotations[cobra.BashCompOneRequiredFlag] + if !ok || len(required) == 0 || required[0] != "true" { + t.Errorf("op-host flag is not marked required: annotations=%v", annotations) + } +} + +func TestSubcommandsAreRegisteredOnRoot(t *testing.T) { + t.Parallel() + want := map[string]bool{ + "apply": true, + "destroy": true, + "build": true, + "check": true, + "tags": true, + "state": true, + "secret-to-genesissecret": true, + } + for _, c := range rootCmd.Commands() { + delete(want, c.Name()) + } + if len(want) != 0 { + t.Errorf("subcommands missing from root: %v", want) + } +} + +func TestStateSubcommandsAreRegistered(t *testing.T) { + t.Parallel() + want := map[string]bool{ + "list": true, + "diff": true, + "sync": true, + } + for _, c := range stateCmd.Commands() { + delete(want, c.Name()) + } + if len(want) != 0 { + t.Errorf("state subcommands missing: %v", want) + } +} diff --git a/cmd/tags_test.go b/cmd/tags_test.go new file mode 100644 index 0000000..2d59115 --- /dev/null +++ b/cmd/tags_test.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" +) + +func resetTagsFlags(t *testing.T) { + t.Helper() + t.Cleanup(func() { + _ = rootCmd.PersistentFlags().Set("file-path", "tazuna.yaml") + _ = rootCmd.PersistentFlags().Set("log-level", "info") + }) +} + +func runTags(t *testing.T, args []string) error { + t.Helper() + resetTagsFlags(t) + tagsCmd.SetOut(io.Discard) + tagsCmd.SetErr(io.Discard) + if err := tagsCmd.ParseFlags(args); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + tagsCmd.SetContext(context.Background()) + return tagsCmd.RunE(tagsCmd, []string{}) +} + +func TestTagsCmd_Success(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tazuna.yaml") + if err := os.WriteFile(path, []byte(`apiVersion: tazuna.pepabo.com/v1 +kind: Tazuna +spec: + manifests: + - name: web + type: kustomize + path: ./web + tags: + - frontend + - name: api + type: kustomize + path: ./api + tags: + - backend +`), 0o644); err != nil { + t.Fatalf("write tmp file: %v", err) + } + + if err := runTags(t, []string{"-f", path}); err != nil { + t.Fatalf("expected nil error, got: %v", err) + } +} + +func TestTagsCmd_ValidationFails(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tazuna.yaml") + if err := os.WriteFile(path, []byte(`apiVersion: tazuna.pepabo.com/v1 +kind: Tazuna +spec: + manifests: + - name: bad +`), 0o644); err != nil { + t.Fatalf("write tmp file: %v", err) + } + if err := runTags(t, []string{"-f", path}); err == nil { + t.Fatal("expected validation error, got nil") + } +} + +func TestTagsCmd_MissingFile(t *testing.T) { + if err := runTags(t, []string{"-f", filepath.Join(t.TempDir(), "missing.yaml")}); err == nil { + t.Fatal("expected error for missing file, got nil") + } +} From 0730600eb961ad5f4caac30f2b1a90d2da85865e Mon Sep 17 00:00:00 2001 From: drumato Date: Sat, 16 May 2026 09:48:27 +0900 Subject: [PATCH 11/13] refactor(cmd): share --tags flag and add filter to tags subcommand apply / destroy / build each redeclared `--tags / -t` inline with slightly different help text and a 3-line getter, while the `tags` subcommand had no --tags at all even though "show me only these tags" is the obvious UX. Add cmd/tags_flag.go with addTagsFlag(cmd, usage) and getTags(cmd) so the flag's name, shorthand, default, and reader live in one place, and register it on the four subcommands. `tazuna tags --tags X,Y` now restricts the listing; output keys are also sorted for stable display. Closes #34 --- cmd/apply.go | 7 ++----- cmd/build.go | 7 ++----- cmd/destroy.go | 7 ++----- cmd/flags_test.go | 6 ++++++ cmd/tags.go | 40 +++++++++++++++++++++++++++++++++------- cmd/tags_flag.go | 21 +++++++++++++++++++++ cmd/tags_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 105 insertions(+), 22 deletions(-) create mode 100644 cmd/tags_flag.go diff --git a/cmd/apply.go b/cmd/apply.go index 4aadcd2..83076a7 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -40,10 +40,7 @@ Examples: return err } - tags := []string{} - if v, err := cmd.Flags().GetStringSlice("tags"); err == nil { - tags = v - } + tags := getTags(cmd) k8sClient, err := cliutil.NewK8sClient() if err != nil { @@ -81,7 +78,7 @@ Examples: } func init() { - applyCmd.Flags().StringSliceP("tags", "t", []string{}, "Filter manifests by tag; only matching tags are applied") + addTagsFlag(applyCmd, "Filter manifests by tag; only matching tags are applied") addORASPullFlags(applyCmd) rootCmd.AddCommand(applyCmd) diff --git a/cmd/build.go b/cmd/build.go index 0274181..92496fc 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -35,10 +35,7 @@ Examples: return err } - tags := []string{} - if v, err := cmd.Flags().GetStringSlice("tags"); err == nil { - tags = v - } + tags := getTags(cmd) k8sClient, err := cliutil.NewK8sClient() if err != nil { @@ -72,7 +69,7 @@ Examples: func init() { buildCmd.Flags().String("cluster-name", "kind-tazuna", "cluster name") - buildCmd.Flags().StringSliceP("tags", "t", []string{}, "Filter manifests by tag; only matching tags are built") + addTagsFlag(buildCmd, "Filter manifests by tag; only matching tags are built") addORASPullFlags(buildCmd) rootCmd.AddCommand(buildCmd) } diff --git a/cmd/destroy.go b/cmd/destroy.go index 266804f..0e7b277 100644 --- a/cmd/destroy.go +++ b/cmd/destroy.go @@ -45,10 +45,7 @@ Examples: if err != nil { return err } - tags := []string{} - if v, err := cmd.Flags().GetStringSlice("tags"); err == nil { - tags = v - } + tags := getTags(cmd) orasOpts, err := buildORASPullOptions(cmd) if err != nil { return err @@ -96,7 +93,7 @@ Examples: func init() { destroyCmd.Flags().Bool("force", false, "Delete without confirmation") - destroyCmd.Flags().StringSliceP("tags", "t", []string{}, "Filter manifests by tag; only matching tags are destroyed") + addTagsFlag(destroyCmd, "Filter manifests by tag; only matching tags are destroyed") addORASPullFlags(destroyCmd) rootCmd.AddCommand(destroyCmd) } diff --git a/cmd/flags_test.go b/cmd/flags_test.go index 9d93a04..3070bfe 100644 --- a/cmd/flags_test.go +++ b/cmd/flags_test.go @@ -83,6 +83,12 @@ func TestSubcommandFlags(t *testing.T) { {name: "fix", defaultVal: "false"}, }, }, + { + cmd: tagsCmd, + flags: []flagSpec{ + {name: "tags", defaultVal: "[]"}, + }, + }, { cmd: stateSyncCmd, flags: []flagSpec{ diff --git a/cmd/tags.go b/cmd/tags.go index fcb29c6..3c95005 100644 --- a/cmd/tags.go +++ b/cmd/tags.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "log/slog" + "sort" "github.com/cockroachdb/errors" "github.com/pepabo/tazuna/cmd/internal/cliutil" @@ -17,8 +18,11 @@ var tagsCmd = &cobra.Command{ Short: "List the tags defined in tazuna.yaml", Long: `List the tags attached to manifests in tazuna.yaml together with the manifest names associated with each tag. +When --tags is specified, output is restricted to those tag names. + Examples: - tazuna tags -f tazuna.yaml`, + tazuna tags -f tazuna.yaml + tazuna tags -f tazuna.yaml --tags frontend,backend`, RunE: func(cmd *cobra.Command, args []string) error { path, err := cmd.Flags().GetString("file-path") if err != nil { @@ -42,16 +46,38 @@ Examples: return errors.Wrapf(err, "failed to list tags for tazuna.yaml at %s", path) } - for tag, relatedNames := range tags { - fmt.Printf("%s:\n", tag) - for _, name := range relatedNames { - fmt.Printf("- %s\n", name) - } - } + filter := getTags(cmd) + printTagsFiltered(cmd.OutOrStdout(), tags, filter) return nil }, } +// printTagsFiltered writes the tag→names map to w. When filter is non-empty, +// only the listed tag names are emitted; output is sorted for determinism. +func printTagsFiltered(w io.Writer, tags map[string][]string, filter []string) { + want := tags + if len(filter) > 0 { + want = make(map[string][]string, len(filter)) + for _, t := range filter { + if names, ok := tags[t]; ok { + want[t] = names + } + } + } + keys := make([]string, 0, len(want)) + for k := range want { + keys = append(keys, k) + } + sort.Strings(keys) + for _, tag := range keys { + fmt.Fprintf(w, "%s:\n", tag) + for _, name := range want[tag] { + fmt.Fprintf(w, "- %s\n", name) + } + } +} + func init() { + addTagsFlag(tagsCmd, "Restrict output to the listed tags") rootCmd.AddCommand(tagsCmd) } diff --git a/cmd/tags_flag.go b/cmd/tags_flag.go new file mode 100644 index 0000000..e8a10be --- /dev/null +++ b/cmd/tags_flag.go @@ -0,0 +1,21 @@ +package cmd + +import "github.com/spf13/cobra" + +// addTagsFlag attaches the standard --tags / -t flag to cmd. Tazuna treats +// "filter the manifest set by tag" as a cross-cutting concern, so every +// subcommand that operates on manifests should register the flag through this +// helper rather than redeclaring it inline. +func addTagsFlag(cmd *cobra.Command, usage string) { + cmd.Flags().StringSliceP("tags", "t", []string{}, usage) +} + +// getTags reads the --tags slice from cmd. A missing flag (for subcommands +// that didn't register one) is treated as "no filter". +func getTags(cmd *cobra.Command) []string { + tags, err := cmd.Flags().GetStringSlice("tags") + if err != nil { + return nil + } + return tags +} diff --git a/cmd/tags_test.go b/cmd/tags_test.go index 2d59115..9d59228 100644 --- a/cmd/tags_test.go +++ b/cmd/tags_test.go @@ -1,13 +1,52 @@ package cmd import ( + "bytes" "context" "io" "os" "path/filepath" + "strings" "testing" ) +func TestPrintTagsFiltered_NoFilter(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + printTagsFiltered(&buf, map[string][]string{ + "frontend": {"web"}, + "backend": {"api"}, + }, nil) + got := buf.String() + // keys are sorted, so backend appears before frontend + if !strings.HasPrefix(got, "backend:\n- api\nfrontend:\n- web\n") { + t.Errorf("unexpected output:\n%s", got) + } +} + +func TestPrintTagsFiltered_WithFilter(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + printTagsFiltered(&buf, map[string][]string{ + "frontend": {"web"}, + "backend": {"api"}, + "infra": {"vpc"}, + }, []string{"frontend", "infra", "unknown"}) + got := buf.String() + if strings.Contains(got, "backend") { + t.Errorf("filter leaked backend: %s", got) + } + if !strings.Contains(got, "frontend:\n- web") { + t.Errorf("frontend missing: %s", got) + } + if !strings.Contains(got, "infra:\n- vpc") { + t.Errorf("infra missing: %s", got) + } + if strings.Contains(got, "unknown") { + t.Errorf("unknown tag should be silently dropped: %s", got) + } +} + func resetTagsFlags(t *testing.T) { t.Helper() t.Cleanup(func() { From 17d17f558b7245be26ca101bc63c9fca305c16d0 Mon Sep 17 00:00:00 2001 From: drumato <41734896+Drumato@users.noreply.github.com> Date: Sat, 16 May 2026 10:00:40 +0900 Subject: [PATCH 12/13] refactor: drop direct yaml.v3 dependency in favour of sigs.k8s.io/yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tazuna had both yaml.v3 and sigs.k8s.io/yaml as direct dependencies: yaml.v3 decoded the Tazuna / GenesisSecret CRs (via `yaml:` struct tags), while sigs.k8s.io/yaml was used only to marshal the corev1.Secret that GenesisSecret.Build produces. Maintaining both parsers means every api/v1 struct needs synchronised `yaml:` and `json:` tags, and divergent parser behaviour (already pulled in transitively via goccy/go-yaml, ghodss/yaml, etc.) can produce hard-to-repro bugs. Unify on sigs.k8s.io/yaml: - Replace `yaml:` tags with `json:` tags across api/v1, fixing the pre-existing mismatch on TazunaHint where YAML wrote `apiVersion` but JSON wrote `APIVersion`. Update the FormatJSON test accordingly. - Swap `gopkg.in/yaml.v3` imports for `sigs.k8s.io/yaml` in cmd/, pkg/ source files, and (//go:build integration) test files. The streaming decoder pattern (Open → NewDecoder.Decode → defer Close) becomes ReadFile → Unmarshal since kyaml has no Decoder type. - `go mod tidy` demotes yaml.v3 to an indirect dependency pulled by upstream packages — Tazuna no longer pins it. Closes #32 --- api/v1/genesissecret_types.go | 32 +++++------ api/v1/genesissecret_types_test.go | 2 +- api/v1/hint_types.go | 28 ++++----- api/v1/hint_types_test.go | 2 +- api/v1/oras_types.go | 22 ++++---- api/v1/oras_types_test.go | 2 +- api/v1/provider_types.go | 8 +-- api/v1/provider_types_test.go | 2 +- api/v1/tazuna_types.go | 78 +++++++++++++------------- api/v1/tazuna_types_test.go | 2 +- api/v1/testplugin_types.go | 34 +++++------ api/v1/testplugin_types_test.go | 2 +- cmd/check.go | 2 +- cmd/internal/cliutil/cliutil.go | 17 ++---- go.mod | 2 +- pkg/hint/format.go | 2 +- pkg/hint/format_test.go | 4 +- pkg/hint/hint.go | 2 +- pkg/manager/genesis_secret.go | 38 ++++--------- pkg/manifest/manifest.go | 2 +- pkg/runner/apply.go | 11 +--- pkg/runner/apply_integration_test.go | 29 +++------- pkg/runner/destroy_integration_test.go | 38 +++---------- pkg/runner/secret_to_genesissecret.go | 2 +- pkg/runner/tags_integration_test.go | 29 +++------- pkg/runner/tags_test.go | 11 +--- 26 files changed, 159 insertions(+), 244 deletions(-) diff --git a/api/v1/genesissecret_types.go b/api/v1/genesissecret_types.go index 23da448..02e2b8a 100644 --- a/api/v1/genesissecret_types.go +++ b/api/v1/genesissecret_types.go @@ -1,40 +1,40 @@ package v1 type GenesisSecret struct { - Spec GenesisSecretSpec `yaml:"spec"` + Spec GenesisSecretSpec `json:"spec"` } type GenesisSecretSpec struct { - Provider string `yaml:"provider"` - Secrets []GenesisSecretGenerate `yaml:"secrets"` - Outputs []GenesisSecretOutput `yaml:"outputs"` + Provider string `json:"provider"` + Secrets []GenesisSecretGenerate `json:"secrets"` + Outputs []GenesisSecretOutput `json:"outputs"` } type GenesisSecretGenerate struct { // PreferLabelはID->ValueのマッピングではなくLabel->Valueを作る // カスタムのkey-valueを作るとIDがランダム文字列になるので、それを可能にするために定義する - PreferLabel bool `yaml:"preferLabel"` - URI string `yaml:"uri"` - Items map[string]GenesisSecretGenerateItem `yaml:"items"` + PreferLabel bool `json:"preferLabel"` + URI string `json:"uri"` + Items map[string]GenesisSecretGenerateItem `json:"items"` } type GenesisSecretGenerateItem struct { - MapTo string `yaml:"mapTo"` + MapTo string `json:"mapTo"` } type GenesisSecretOutput struct { - Stdout *GenesisSecretOutputStdout `yaml:"stdout,omitempty"` - KubernetesSecret *GenesisSecretOutputKubernetesSecret `yaml:"kubernetesSecret,omitempty"` + Stdout *GenesisSecretOutputStdout `json:"stdout,omitempty"` + KubernetesSecret *GenesisSecretOutputKubernetesSecret `json:"kubernetesSecret,omitempty"` } type GenesisSecretOutputStdout struct{} type GenesisSecretOutputKubernetesSecret struct { - Context string `yaml:"context"` - Namespace string `yaml:"namespace"` - Name string `yaml:"name"` - Labels map[string]string `yaml:"labels"` - Annotations map[string]string `yaml:"annotations"` + Context string `json:"context"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` // corev1.SecretType を指定する - Type string `yaml:"type"` + Type string `json:"type"` } diff --git a/api/v1/genesissecret_types_test.go b/api/v1/genesissecret_types_test.go index e099e36..50655dd 100644 --- a/api/v1/genesissecret_types_test.go +++ b/api/v1/genesissecret_types_test.go @@ -3,7 +3,7 @@ package v1 import ( "testing" - "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" ) func TestGenesisSecret_RoundTrip(t *testing.T) { diff --git a/api/v1/hint_types.go b/api/v1/hint_types.go index 9ae8879..0c4b84f 100644 --- a/api/v1/hint_types.go +++ b/api/v1/hint_types.go @@ -48,38 +48,38 @@ const ( // MergeVarsWithHintの実行時に、個別varの検証後に評価されます。 type HintRule struct { // Type はルールの種別です。現在は "oneof_required" のみ対応。 - Type HintRuleType `yaml:"type" json:"type"` + Type HintRuleType `json:"type"` // Vars はルールの対象となるvar名のリストです。2件以上が必要です。 - Vars []string `yaml:"vars" json:"vars"` + Vars []string `json:"vars"` // Message はバリデーションエラー時に表示するカスタムメッセージです。 - Message string `yaml:"message,omitempty" json:"message,omitempty"` + Message string `json:"message,omitempty"` } // TazunaHint はtazuna.hint.yamlのルートリソースです type TazunaHint struct { - APIVersion string `yaml:"apiVersion" json:"APIVersion"` - Kind string `yaml:"kind" json:"Kind"` - Vars map[string]HintVar `yaml:"vars" json:"Vars"` + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Vars map[string]HintVar `json:"vars"` // Rules はvar横断のトップレベルバリデーションルールです。 - Rules []HintRule `yaml:"rules,omitempty" json:"Rules,omitempty"` + Rules []HintRule `json:"rules,omitempty"` } // HintVar はhint varsの定義です type HintVar struct { - Type HintVarType `yaml:"type" json:"type"` - Required bool `yaml:"required" json:"required"` - Default any `yaml:"default,omitempty" json:"default,omitempty"` - Description string `yaml:"description,omitempty" json:"description,omitempty"` + Type HintVarType `json:"type"` + Required bool `json:"required"` + Default any `json:"default,omitempty"` + Description string `json:"description,omitempty"` // Format はstring型varに対するフォーマット検証ルールです。 // string型以外のvarに指定するとValidateHintでエラーになります。 // 値が空文字列(ゼロ値注入を含む)の場合、検証はスキップされます。 - Format HintFormat `yaml:"format,omitempty" json:"format,omitempty"` + Format HintFormat `json:"format,omitempty"` // RequiredWith は、指定されたvarのいずれかがユーザーから提供された場合に、 // このvarも必須になることを示します。 // required:trueとの併用は矛盾するためValidateHintでエラーになります。 - RequiredWith []string `yaml:"required_with,omitempty" json:"required_with,omitempty"` + RequiredWith []string `json:"required_with,omitempty"` // RequiredWithout は、指定されたvarが全てユーザーから未提供の場合に、 // このvarが必須になることを示します。 // required:trueとの併用は矛盾するためValidateHintでエラーになります。 - RequiredWithout []string `yaml:"required_without,omitempty" json:"required_without,omitempty"` + RequiredWithout []string `json:"required_without,omitempty"` } diff --git a/api/v1/hint_types_test.go b/api/v1/hint_types_test.go index 9ef0e9c..7d2c40a 100644 --- a/api/v1/hint_types_test.go +++ b/api/v1/hint_types_test.go @@ -3,7 +3,7 @@ package v1 import ( "testing" - "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" ) func TestTazunaHint_RoundTrip(t *testing.T) { diff --git a/api/v1/oras_types.go b/api/v1/oras_types.go index c789752..28b8a82 100644 --- a/api/v1/oras_types.go +++ b/api/v1/oras_types.go @@ -16,32 +16,32 @@ type ManifestORAS struct { // Reference はOCI artifactのreferenceを指定します。 // tag形式 (`ghcr.io/example/foo:v1.0.0`) と digest形式 // (`ghcr.io/example/foo@sha256:...`) の両方を受け付けます。 - Reference string `yaml:"reference"` + Reference string `json:"reference"` // Target はartifact展開後のルートからの相対サブパスを指定します。 // 省略時はrootを指します。 - Target string `yaml:"target,omitempty"` + Target string `json:"target,omitempty"` // PlainHTTP はregistryへの接続にHTTP (非TLS) を使うかどうかを指定します。 - PlainHTTP bool `yaml:"plainHTTP,omitempty"` + PlainHTTP bool `json:"plainHTTP,omitempty"` // InsecureSkipVerify はregistry接続時のTLS証明書検証をスキップします。 - InsecureSkipVerify bool `yaml:"insecureSkipVerify,omitempty"` + InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` // Auth はregistryの認証情報をoverrideします。省略時は docker config.json を使用します。 - Auth *ORASAuth `yaml:"auth,omitempty"` + Auth *ORASAuth `json:"auth,omitempty"` // Delegate はpull後の委譲先managerの設定を指定します。 - Delegate ORASDelegate `yaml:"delegate"` + Delegate ORASDelegate `json:"delegate"` } // ORASAuth はORAS pull時の認証情報のoverrideを表します。 type ORASAuth struct { - Username string `yaml:"username,omitempty"` - Password string `yaml:"password,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` } // ORASDelegate はORAS managerが委譲する先のmanager設定を表します。 type ORASDelegate struct { // Type は委譲先managerの種別 (helmfile / kustomize) を指定します。 - Type ORASDelegateType `yaml:"type"` + Type ORASDelegateType `json:"type"` // Helmfile は Type が helmfile の場合に委譲先に渡す設定です。 - Helmfile *ManifestHelmfile `yaml:"helmfile,omitempty"` + Helmfile *ManifestHelmfile `json:"helmfile,omitempty"` // Kustomize は Type が kustomize の場合に委譲先に渡す設定です。 - Kustomize *ManifestKustomize `yaml:"kustomize,omitempty"` + Kustomize *ManifestKustomize `json:"kustomize,omitempty"` } diff --git a/api/v1/oras_types_test.go b/api/v1/oras_types_test.go index 12a1dc1..5fd1eda 100644 --- a/api/v1/oras_types_test.go +++ b/api/v1/oras_types_test.go @@ -3,7 +3,7 @@ package v1 import ( "testing" - "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" ) func TestManifestORAS_RoundTrip_Tag(t *testing.T) { diff --git a/api/v1/provider_types.go b/api/v1/provider_types.go index 4280294..f7a3da2 100644 --- a/api/v1/provider_types.go +++ b/api/v1/provider_types.go @@ -1,16 +1,16 @@ package v1 type Provider struct { - Spec ProviderSpec `yaml:"spec"` + Spec ProviderSpec `json:"spec"` } type ProviderSpec struct { - Requirements []ProviderRequirement `yaml:"requirements"` + Requirements []ProviderRequirement `json:"requirements"` } type ProviderRequirement struct { - Name string `yaml:"name"` - Command []string `yaml:"command"` + Name string `json:"name"` + Command []string `json:"command"` } func (Provider) GetKind() string { diff --git a/api/v1/provider_types_test.go b/api/v1/provider_types_test.go index b6e534f..3913505 100644 --- a/api/v1/provider_types_test.go +++ b/api/v1/provider_types_test.go @@ -3,7 +3,7 @@ package v1 import ( "testing" - "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" ) func TestProvider_RoundTrip(t *testing.T) { diff --git a/api/v1/tazuna_types.go b/api/v1/tazuna_types.go index 42708b9..acdacd3 100644 --- a/api/v1/tazuna_types.go +++ b/api/v1/tazuna_types.go @@ -11,11 +11,11 @@ const ( type Tazuna struct { // APIVersion は Kubernetes manifest と同形式の TypeMeta フィールドです。 // 設定する場合は TazunaAPIVersion と一致している必要があります。 - APIVersion string `yaml:"apiVersion,omitempty"` + APIVersion string `json:"apiVersion,omitempty"` // Kind は Kubernetes manifest と同形式の TypeMeta フィールドです。 // 設定する場合は TazunaKind と一致している必要があります。 - Kind string `yaml:"kind,omitempty"` - Spec TazunaSpec `yaml:"spec"` + Kind string `json:"kind,omitempty"` + Spec TazunaSpec `json:"spec"` } // ContextMatchMode はcontext_matchesの評価モードを定義します @@ -31,46 +31,46 @@ const ( type TazunaSpec struct { // ContextMatchesは現在のkubeconfigコンテキスト名がマッチすべき正規表現パターンのリストです // 指定した場合、apply/destroy時にコンテキスト名がパターンにマッチしないとエラーになります - ContextMatches []string `yaml:"context_matches,omitempty"` + ContextMatches []string `json:"context_matches,omitempty"` // ContextMatchModeはcontext_matchesの評価モードです("or" または "and"、デフォルトは "or") - ContextMatchMode ContextMatchMode `yaml:"context_match_mode,omitempty"` - Manifests []Manifest `yaml:"manifests"` + ContextMatchMode ContextMatchMode `json:"context_match_mode,omitempty"` + Manifests []Manifest `json:"manifests"` // Testsはすべてのマニフェスト適用が終わったあとに実行されます - Tests []TestPluginSpec `yaml:"tests"` + Tests []TestPluginSpec `json:"tests"` } // IncludeFile はincludeするファイルを定義します type IncludeFile struct { // Path はincludeするファイルのパス(tazuna.yamlからの相対パス) - Path string `yaml:"path"` + Path string `json:"path"` } // Manifest はtazunaのマニフェスト管理方式を定義します type Manifest struct { - Name string `yaml:"name,omitempty"` // マニフェストの名前 - Description string `yaml:"description,omitempty"` // マニフェストの説明 + Name string `json:"name,omitempty"` // マニフェストの名前 + Description string `json:"description,omitempty"` // マニフェストの説明 // Includes はincludeするファイルのリストを指定します // includesが指定された場合、他のフィールド(Type, Path, Tags など)は無視されます - Includes []IncludeFile `yaml:"includes,omitempty"` + Includes []IncludeFile `json:"includes,omitempty"` // Path はマニフェストのパスを指定します // GenesisSecretの場合はGenesisSecretのリソースマニフェストのパス // Kustomizeの場合はkustomization.yamlがあるディレクトリ // Helmfileの場合はhelmfile.yamlがあるディレクトリ - Path string `yaml:"path"` - Type ManifestType `yaml:"type"` + Path string `json:"path"` + Type ManifestType `json:"type"` // Tagsはマニフェストに付与するタグを指定します // タグはマニフェストの選択に利用できます // 例えば、`tazuna apply --tags foo,bar`とすると、fooとbarのタグが付与されたマニフェストのみが適用されます - Tags []string `yaml:"tags,omitempty"` - Kustomize *ManifestKustomize `yaml:"kustomize,omitempty"` - GenesisSecret *ManifestGenesisSecret `yaml:"genesisSecret,omitempty"` - Helmfile *ManifestHelmfile `yaml:"helmfile,omitempty"` - Parallel *ManifestParallel `yaml:"parallel,omitempty"` - ORAS *ManifestORAS `yaml:"oras,omitempty"` + Tags []string `json:"tags,omitempty"` + Kustomize *ManifestKustomize `json:"kustomize,omitempty"` + GenesisSecret *ManifestGenesisSecret `json:"genesisSecret,omitempty"` + Helmfile *ManifestHelmfile `json:"helmfile,omitempty"` + Parallel *ManifestParallel `json:"parallel,omitempty"` + ORAS *ManifestORAS `json:"oras,omitempty"` // Testsはマニフェストapply後に行われる各種テストを記載します - Tests []TestPluginSpec `yaml:"tests"` + Tests []TestPluginSpec `json:"tests"` } type ManifestType string @@ -84,18 +84,18 @@ const ( ) type ManifestKustomize struct { - DefaultNamespace string `yaml:"defaultNamespace,omitempty"` // kustomize assetsのデフォルトネームスペース + DefaultNamespace string `json:"defaultNamespace,omitempty"` // kustomize assetsのデフォルトネームスペース } type ManifestGenesisSecret struct{} type ManifestHelmfile struct { - IncludeCRDs bool `yaml:"includeCRDs"` - Vars map[string]HelmFileVar `yaml:"vars,omitempty"` - DefaultNamespace string `yaml:"defaultNamespace,omitempty"` // helmfile assetsのデフォルトネームスペース - ExtraValueFiles []string `yaml:"extraValueFiles,omitempty"` // 追加のvalue filesを指定 - Wait bool `yaml:"wait,omitempty"` // helmfile syncに--waitオプションを渡す - TimeoutSeconds int `yaml:"timeoutSeconds,omitempty"` // リソースのReady待機のタイムアウト秒数 - KubeVersion string `yaml:"kubeVersion,omitempty"` // helm templateに渡す--kube-versionの値 + IncludeCRDs bool `json:"includeCRDs"` + Vars map[string]HelmFileVar `json:"vars,omitempty"` + DefaultNamespace string `json:"defaultNamespace,omitempty"` // helmfile assetsのデフォルトネームスペース + ExtraValueFiles []string `json:"extraValueFiles,omitempty"` // 追加のvalue filesを指定 + Wait bool `json:"wait,omitempty"` // helmfile syncに--waitオプションを渡す + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` // リソースのReady待機のタイムアウト秒数 + KubeVersion string `json:"kubeVersion,omitempty"` // helm templateに渡す--kube-versionの値 } func DefaultHelmfile() *ManifestHelmfile { @@ -117,22 +117,22 @@ const ( type HelmFileVar struct { // Fromはどこからhelmfile varの値を取得するかを指定します。 // 現状は `env` と `op`, `static` をサポートしています - From string `yaml:"from,omitempty"` - Op *OnePasswordVaultSelector `yaml:"op,omitempty"` - Static *string `yaml:"static,omitempty"` // staticな値を指定する - StaticSlice []string `yaml:"staticSlice,omitempty"` // staticなslice値を指定する - StaticMap map[string]string `yaml:"staticMap,omitempty"` // staticなmap値を指定する - Env *string `yaml:"env,omitempty"` // 環境変数から値を取得する + From string `json:"from,omitempty"` + Op *OnePasswordVaultSelector `json:"op,omitempty"` + Static *string `json:"static,omitempty"` // staticな値を指定する + StaticSlice []string `json:"staticSlice,omitempty"` // staticなslice値を指定する + StaticMap map[string]string `json:"staticMap,omitempty"` // staticなmap値を指定する + Env *string `json:"env,omitempty"` // 環境変数から値を取得する } type OnePasswordVaultSelector struct { - Key string `yaml:"key"` // 1PasswordのFieldをIDかLabelのどちらから取ってくるか - Vault string `yaml:"vault"` // 1PasswordのVault名 - Item string `yaml:"item"` // 1PasswordのItem名 - Field string `yaml:"field"` // 1PasswordのField名 + Key string `json:"key"` // 1PasswordのFieldをIDかLabelのどちらから取ってくるか + Vault string `json:"vault"` // 1PasswordのVault名 + Item string `json:"item"` // 1PasswordのItem名 + Field string `json:"field"` // 1PasswordのField名 } type ManifestParallel struct { // Childrenはマニフェストの子マニフェストを定義します - Children []Manifest `yaml:"children,omitempty"` + Children []Manifest `json:"children,omitempty"` } diff --git a/api/v1/tazuna_types_test.go b/api/v1/tazuna_types_test.go index d163dc6..de11de1 100644 --- a/api/v1/tazuna_types_test.go +++ b/api/v1/tazuna_types_test.go @@ -3,7 +3,7 @@ package v1 import ( "testing" - "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" ) func roundTripTazuna(t *testing.T, src Tazuna) Tazuna { diff --git a/api/v1/testplugin_types.go b/api/v1/testplugin_types.go index 0eef6f3..e346c96 100644 --- a/api/v1/testplugin_types.go +++ b/api/v1/testplugin_types.go @@ -1,21 +1,21 @@ package v1 type TestPluginSpec struct { - Type TestPluginType `yaml:"type"` + Type TestPluginType `json:"type"` // N回の連続したテスト関数の成功を、テストプラグインの通過とする // 指定されていない場合、一度でも成功したらOKとする - MinConsecutiveSuccessCount int `yaml:"minConsecutiveSuccessCount"` + MinConsecutiveSuccessCount int `json:"minConsecutiveSuccessCount"` // N回の連続したテスト関数の失敗を、テストプラグインの失敗とする // 指定されていない場合無視される - MinConsecutiveFailureCount int `yaml:"minConsecutiveFailureCount"` + MinConsecutiveFailureCount int `json:"minConsecutiveFailureCount"` // テストプラグイン自体の失敗とするタイムアウト秒 // 指定されなければSuccessするまで待ち続ける - TimeoutSeconds int `yaml:"timeoutSeconds"` + TimeoutSeconds int `json:"timeoutSeconds"` // テスト関数の実行の間にいれるinterval // 指定されなければ即座に再実行される - IntervalSeconds int `yaml:"intervalSeconds"` - WaitUntil *WaitUntilArgs `yaml:"waitUntil,omitempty"` - ExistNonExist *ExistNonExistArgs `yaml:"existNonExist,omitempty"` + IntervalSeconds int `json:"intervalSeconds"` + WaitUntil *WaitUntilArgs `json:"waitUntil,omitempty"` + ExistNonExist *ExistNonExistArgs `json:"existNonExist,omitempty"` } type TestPluginType string @@ -26,20 +26,20 @@ const ( ) type WaitUntilArgs struct { - Resource WaitUntilResource `yaml:"resource"` - Namespace string `yaml:"namespace"` - Name string `yaml:"name"` - Condition string `yaml:"condition"` + Resource WaitUntilResource `json:"resource"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Condition string `json:"condition"` } type WaitUntilResource struct { - APIVersion string `yaml:"apiVersion"` - Kind string `yaml:"kind"` + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` } type ExistNonExistArgs struct { - Resource WaitUntilResource `yaml:"resource"` - Namespace string `yaml:"namespace"` - Name string `yaml:"name"` - ShouldExist bool `yaml:"shouldExist"` + Resource WaitUntilResource `json:"resource"` + Namespace string `json:"namespace"` + Name string `json:"name"` + ShouldExist bool `json:"shouldExist"` } diff --git a/api/v1/testplugin_types_test.go b/api/v1/testplugin_types_test.go index 2de7980..65cd8ee 100644 --- a/api/v1/testplugin_types_test.go +++ b/api/v1/testplugin_types_test.go @@ -3,7 +3,7 @@ package v1 import ( "testing" - "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" ) func TestTestPluginSpec_WaitUntil_RoundTrip(t *testing.T) { diff --git a/cmd/check.go b/cmd/check.go index 8308a04..cd2e61e 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -10,7 +10,7 @@ import ( "github.com/pepabo/tazuna/pkg/runner" "github.com/pepabo/tazuna/pkg/validator" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" ) var checkCmd = &cobra.Command{ diff --git a/cmd/internal/cliutil/cliutil.go b/cmd/internal/cliutil/cliutil.go index 4327dcf..d50a1a6 100644 --- a/cmd/internal/cliutil/cliutil.go +++ b/cmd/internal/cliutil/cliutil.go @@ -11,9 +11,9 @@ import ( "github.com/cockroachdb/errors" v1 "github.com/pepabo/tazuna/api/v1" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" ) // ParseLogLevel maps the textual log level used by Tazuna's --log-level flag to @@ -41,21 +41,14 @@ func NewLogger(cmd *cobra.Command) (*slog.Logger, error) { return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: ParseLogLevel(logLevelS)})), nil } -// LoadTazunaYAML opens path, decodes it as a v1.Tazuna document, and closes -// the file. Decoding and close errors are joined so neither is dropped. -func LoadTazunaYAML(path string) (_ *v1.Tazuna, err error) { - f, err := os.Open(path) +// LoadTazunaYAML reads path and decodes it as a v1.Tazuna document. +func LoadTazunaYAML(path string) (*v1.Tazuna, error) { + data, err := os.ReadFile(path) if err != nil { return nil, errors.WithStack(err) } - defer func() { - if cerr := f.Close(); cerr != nil { - err = errors.Join(err, errors.WithStack(cerr)) - } - }() - tazuna := v1.Tazuna{} - if err := yaml.NewDecoder(f).Decode(&tazuna); err != nil { + if err := yaml.Unmarshal(data, &tazuna); err != nil { return nil, errors.WithStack(err) } return &tazuna, nil diff --git a/go.mod b/go.mod index 6f07d0b..8d90560 100644 --- a/go.mod +++ b/go.mod @@ -42,7 +42,6 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.28.0 - gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.36.0 k8s.io/apimachinery v0.36.0 k8s.io/client-go v0.36.0 @@ -380,6 +379,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect helm.sh/helm/v3 v3.21.0 // indirect helm.sh/helm/v4 v4.1.4 // indirect k8s.io/apiextensions-apiserver v0.36.0 // indirect diff --git a/pkg/hint/format.go b/pkg/hint/format.go index e18b759..82a9cc8 100644 --- a/pkg/hint/format.go +++ b/pkg/hint/format.go @@ -8,7 +8,7 @@ import ( "github.com/cockroachdb/errors" v1 "github.com/pepabo/tazuna/api/v1" - "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" ) // FormatHuman はhintをhuman-readableな形式で出力します。 diff --git a/pkg/hint/format_test.go b/pkg/hint/format_test.go index 7f8d967..3d44fe9 100644 --- a/pkg/hint/format_test.go +++ b/pkg/hint/format_test.go @@ -8,7 +8,7 @@ import ( "github.com/pepabo/tazuna/pkg/hint" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" ) func TestFormatHuman(t *testing.T) { @@ -157,5 +157,5 @@ func TestFormatJSON(t *testing.T) { var parsed map[string]any err = json.Unmarshal([]byte(out), &parsed) require.NoError(t, err) - assert.Equal(t, "tazuna.pepabo.com/v1", parsed["APIVersion"]) + assert.Equal(t, "tazuna.pepabo.com/v1", parsed["apiVersion"]) } diff --git a/pkg/hint/hint.go b/pkg/hint/hint.go index 0aeb41b..fb41528 100644 --- a/pkg/hint/hint.go +++ b/pkg/hint/hint.go @@ -11,7 +11,7 @@ import ( "github.com/cockroachdb/errors" v1 "github.com/pepabo/tazuna/api/v1" - "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" ) const HintFileName = "tazuna.hint.yaml" diff --git a/pkg/manager/genesis_secret.go b/pkg/manager/genesis_secret.go index 3cb262e..2015f65 100644 --- a/pkg/manager/genesis_secret.go +++ b/pkg/manager/genesis_secret.go @@ -9,8 +9,7 @@ import ( "github.com/cockroachdb/errors" v1 "github.com/pepabo/tazuna/api/v1" "github.com/pepabo/tazuna/pkg/genesissecret" - "gopkg.in/yaml.v3" - kyaml "sigs.k8s.io/yaml" + "sigs.k8s.io/yaml" corev1 "k8s.io/api/core/v1" @@ -38,18 +37,13 @@ func NewGenesisSecret( // Apply implements Manager. func (g *GenesisSecret) Apply(ctx context.Context, logger *slog.Logger, m v1.Manifest) error { - f, err := os.Open(m.Path) + data, err := os.ReadFile(m.Path) if err != nil { return errors.WithStack(err) } - defer func() { - if cerr := f.Close(); cerr != nil { - err = errors.WithStack(cerr) - } - }() genesisSecret := v1.GenesisSecret{} - if err := yaml.NewDecoder(f).Decode(&genesisSecret); err != nil { + if err := yaml.Unmarshal(data, &genesisSecret); err != nil { return errors.WithStack(err) } @@ -98,19 +92,14 @@ func (g *GenesisSecret) Apply(ctx context.Context, logger *slog.Logger, m v1.Man } // Destroy implements Manager. -func (g *GenesisSecret) Destroy(ctx context.Context, logger *slog.Logger, m v1.Manifest) (err error) { - f, err := os.Open(m.Path) +func (g *GenesisSecret) Destroy(ctx context.Context, logger *slog.Logger, m v1.Manifest) error { + data, err := os.ReadFile(m.Path) if err != nil { return errors.WithStack(err) } - defer func() { - if cerr := f.Close(); cerr != nil { - err = errors.WithStack(cerr) - } - }() genesisSecret := v1.GenesisSecret{} - if err := yaml.NewDecoder(f).Decode(&genesisSecret); err != nil { + if err := yaml.Unmarshal(data, &genesisSecret); err != nil { return errors.WithStack(err) } @@ -152,19 +141,14 @@ func (g *GenesisSecret) Destroy(ctx context.Context, logger *slog.Logger, m v1.M var _ Manager = &GenesisSecret{} // Build implements Manager. -func (g *GenesisSecret) Build(ctx context.Context, logger *slog.Logger, m v1.Manifest) (s string, err error) { - f, err := os.Open(m.Path) +func (g *GenesisSecret) Build(ctx context.Context, logger *slog.Logger, m v1.Manifest) (string, error) { + data, err := os.ReadFile(m.Path) if err != nil { return "", errors.WithStack(err) } - defer func() { - if cerr := f.Close(); cerr != nil { - err = errors.WithStack(cerr) - } - }() genesisSecret := v1.GenesisSecret{} - if err := yaml.NewDecoder(f).Decode(&genesisSecret); err != nil { + if err := yaml.Unmarshal(data, &genesisSecret); err != nil { return "", errors.WithStack(err) } @@ -204,9 +188,7 @@ func (g *GenesisSecret) Build(ctx context.Context, logger *slog.Logger, m v1.Man secret.Type = corev1.SecretType(o.KubernetesSecret.Type) } - // Kubernetes manifestとして正しいyamlを生成するために、 - // sigs.k8s.io/yaml を使う必要がある - out, err := kyaml.Marshal(secret) + out, err := yaml.Marshal(secret) if err != nil { return "", errors.WithStack(err) } diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index 4f0b969..d8a766f 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -3,9 +3,9 @@ package manifest import ( "bytes" - "gopkg.in/yaml.v3" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" ) // convertManifestsToObjects は複数の定義が入ったマニフェスト群を解析し、 diff --git a/pkg/runner/apply.go b/pkg/runner/apply.go index 8dadf8f..06e171d 100644 --- a/pkg/runner/apply.go +++ b/pkg/runner/apply.go @@ -13,7 +13,7 @@ import ( v1 "github.com/pepabo/tazuna/api/v1" "github.com/pepabo/tazuna/pkg/manager" "github.com/pepabo/tazuna/pkg/testplugin" - "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" ) func (t *TazunaRunner) Apply( @@ -139,19 +139,14 @@ func (t *TazunaRunner) expandIncludes(ctx context.Context, tazuna *v1.Tazuna, ta includePath := filepath.Join(baseDir, include.Path) // includeファイルを読み込み - includeFile, err := os.Open(includePath) + includeData, err := os.ReadFile(includePath) if err != nil { return errors.Wrapf(err, "failed to open include file: %s", includePath) } - defer func(f *os.File) { - if cerr := f.Close(); cerr != nil { - t.logger.ErrorContext(ctx, "failed to close include file", slog.String("file", includePath), slog.String("error", cerr.Error())) - } - }(includeFile) // includeファイルをパースして完全なTazuna構造として読み込む var includeTazuna v1.Tazuna - if err := yaml.NewDecoder(includeFile).Decode(&includeTazuna); err != nil { + if err := yaml.Unmarshal(includeData, &includeTazuna); err != nil { return errors.Wrapf(err, "failed to parse include file: %s", includePath) } diff --git a/pkg/runner/apply_integration_test.go b/pkg/runner/apply_integration_test.go index 125525a..46a241b 100644 --- a/pkg/runner/apply_integration_test.go +++ b/pkg/runner/apply_integration_test.go @@ -13,9 +13,9 @@ import ( v1 "github.com/pepabo/tazuna/api/v1" "github.com/pepabo/tazuna/pkg/runner" "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/yaml" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -28,16 +28,11 @@ func TestApplyToCluster_OK(t *testing.T) { client := fake.NewClientBuilder().Build() r := runner.NewTazunaRunner(logger, client, nil) - f, err := os.Open(path) + data, err := os.ReadFile(path) assert.NoError(t, err) - defer func() { - if cerr := f.Close(); cerr != nil { - assert.NoError(t, cerr) - } - }() tazuna := v1.Tazuna{} - err = yaml.NewDecoder(f).Decode(&tazuna) + err = yaml.Unmarshal(data, &tazuna) assert.NoError(t, err) baseDir := filepath.Dir(path) @@ -67,16 +62,11 @@ func TestApplyToCluster_WithTags(t *testing.T) { client := fake.NewClientBuilder().Build() r := runner.NewTazunaRunner(logger, client, nil, runner.WithTags([]string{"kustomize1"})) - f, err := os.Open(path) + data, err := os.ReadFile(path) assert.NoError(t, err) - defer func() { - if cerr := f.Close(); cerr != nil { - assert.NoError(t, cerr) - } - }() tazuna := v1.Tazuna{} - err = yaml.NewDecoder(f).Decode(&tazuna) + err = yaml.Unmarshal(data, &tazuna) assert.NoError(t, err) baseDir := filepath.Dir(path) @@ -106,16 +96,11 @@ func TestApply_WithIncludes(t *testing.T) { client := fake.NewClientBuilder().Build() r := runner.NewTazunaRunner(logger, client, nil) - f, err := os.Open(path) + data, err := os.ReadFile(path) assert.NoError(t, err) - defer func() { - if cerr := f.Close(); cerr != nil { - assert.NoError(t, cerr) - } - }() tazuna := v1.Tazuna{} - err = yaml.NewDecoder(f).Decode(&tazuna) + err = yaml.Unmarshal(data, &tazuna) assert.NoError(t, err) // 元のマニフェスト数を確認(includeマニフェストが1つ) diff --git a/pkg/runner/destroy_integration_test.go b/pkg/runner/destroy_integration_test.go index 684533c..3073c3f 100644 --- a/pkg/runner/destroy_integration_test.go +++ b/pkg/runner/destroy_integration_test.go @@ -13,8 +13,8 @@ import ( v1 "github.com/pepabo/tazuna/api/v1" "github.com/pepabo/tazuna/pkg/runner" "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/yaml" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -39,16 +39,11 @@ func TestDestroyResourcesOnCluster_OK(t *testing.T) { r := runner.NewTazunaRunner(logger, client, nil) - f, err := os.Open(path) + data, err := os.ReadFile(path) assert.NoError(t, err) - defer func() { - if cerr := f.Close(); cerr != nil { - assert.NoError(t, cerr) - } - }() tazuna := v1.Tazuna{} - err = yaml.NewDecoder(f).Decode(&tazuna) + err = yaml.Unmarshal(data, &tazuna) assert.NoError(t, err) baseDir := filepath.Dir(path) @@ -109,16 +104,11 @@ func TestDestroyResourcesOnCluster_WithTags(t *testing.T) { // Create runner with tag filter - only "kustomize1" tagged manifests should be processed r := runner.NewTazunaRunner(logger, client, nil, runner.WithTags([]string{"kustomize1"})) - f, err := os.Open(path) + data, err := os.ReadFile(path) assert.NoError(t, err) - defer func() { - if cerr := f.Close(); cerr != nil { - assert.NoError(t, cerr) - } - }() tazuna := v1.Tazuna{} - err = yaml.NewDecoder(f).Decode(&tazuna) + err = yaml.Unmarshal(data, &tazuna) assert.NoError(t, err) baseDir := filepath.Dir(path) @@ -161,16 +151,11 @@ func TestDestroyResourcesOnCluster_WithNonMatchingTags(t *testing.T) { // Create runner with non-matching tag filter r := runner.NewTazunaRunner(logger, client, nil, runner.WithTags([]string{"nonexistent-tag"})) - f, err := os.Open(path) + data, err := os.ReadFile(path) assert.NoError(t, err) - defer func() { - if cerr := f.Close(); cerr != nil { - assert.NoError(t, cerr) - } - }() tazuna := v1.Tazuna{} - err = yaml.NewDecoder(f).Decode(&tazuna) + err = yaml.Unmarshal(data, &tazuna) assert.NoError(t, err) baseDir := filepath.Dir(path) @@ -206,16 +191,11 @@ func TestDestroyResourcesOnCluster_WithNoTagsSpecified(t *testing.T) { // Create runner with no tag filter (empty tags) r := runner.NewTazunaRunner(logger, client, nil, runner.WithTags([]string{})) - f, err := os.Open(path) + data, err := os.ReadFile(path) assert.NoError(t, err) - defer func() { - if cerr := f.Close(); cerr != nil { - assert.NoError(t, cerr) - } - }() tazuna := v1.Tazuna{} - err = yaml.NewDecoder(f).Decode(&tazuna) + err = yaml.Unmarshal(data, &tazuna) assert.NoError(t, err) baseDir := filepath.Dir(path) diff --git a/pkg/runner/secret_to_genesissecret.go b/pkg/runner/secret_to_genesissecret.go index cf83709..be5c286 100644 --- a/pkg/runner/secret_to_genesissecret.go +++ b/pkg/runner/secret_to_genesissecret.go @@ -17,9 +17,9 @@ import ( v1 "github.com/pepabo/tazuna/api/v1" "github.com/pepabo/tazuna/pkg/op" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" ) // SecretToGenesisSecret は、KubernetesのSecretを1Passwordに保存し、 diff --git a/pkg/runner/tags_integration_test.go b/pkg/runner/tags_integration_test.go index edd420d..dab0eb0 100644 --- a/pkg/runner/tags_integration_test.go +++ b/pkg/runner/tags_integration_test.go @@ -12,8 +12,8 @@ import ( v1 "github.com/pepabo/tazuna/api/v1" "github.com/pepabo/tazuna/pkg/runner" "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/yaml" ) func TestListTags_OK(t *testing.T) { @@ -23,16 +23,11 @@ func TestListTags_OK(t *testing.T) { client := fake.NewClientBuilder().Build() r := runner.NewTazunaRunner(logger, client, nil) - f, err := os.Open(path) + data, err := os.ReadFile(path) assert.NoError(t, err) - defer func() { - if cerr := f.Close(); cerr != nil { - assert.NoError(t, cerr) - } - }() tazuna := v1.Tazuna{} - err = yaml.NewDecoder(f).Decode(&tazuna) + err = yaml.Unmarshal(data, &tazuna) assert.NoError(t, err) tags, err := r.ListTags(context.Background(), &tazuna, path) @@ -52,16 +47,11 @@ func TestListTags_NoTags(t *testing.T) { client := fake.NewClientBuilder().Build() r := runner.NewTazunaRunner(logger, client, nil) - f, err := os.Open(path) + data, err := os.ReadFile(path) assert.NoError(t, err) - defer func() { - if cerr := f.Close(); cerr != nil { - assert.NoError(t, cerr) - } - }() tazuna := v1.Tazuna{} - err = yaml.NewDecoder(f).Decode(&tazuna) + err = yaml.Unmarshal(data, &tazuna) assert.NoError(t, err) tags, err := r.ListTags(context.Background(), &tazuna, path) @@ -116,16 +106,11 @@ func TestListTags_WithIncludes(t *testing.T) { client := fake.NewClientBuilder().Build() r := runner.NewTazunaRunner(logger, client, nil) - f, err := os.Open(path) + data, err := os.ReadFile(path) assert.NoError(t, err) - defer func() { - if cerr := f.Close(); cerr != nil { - assert.NoError(t, cerr) - } - }() tazuna := v1.Tazuna{} - err = yaml.NewDecoder(f).Decode(&tazuna) + err = yaml.Unmarshal(data, &tazuna) assert.NoError(t, err) tags, err := r.ListTags(context.Background(), &tazuna, path) diff --git a/pkg/runner/tags_test.go b/pkg/runner/tags_test.go index a1652d6..794f901 100644 --- a/pkg/runner/tags_test.go +++ b/pkg/runner/tags_test.go @@ -8,7 +8,7 @@ import ( v1 "github.com/pepabo/tazuna/api/v1" "github.com/pepabo/tazuna/pkg/runner" "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" + "sigs.k8s.io/yaml" ) // `tazuna tags` 実行時に nil logger で生成された Runner が @@ -18,16 +18,11 @@ func TestListTags_NilLogger_WithIncludes(t *testing.T) { path := "testdata/include/tazuna.yaml" r := runner.NewTazunaRunner(nil, nil, nil) - f, err := os.Open(path) + data, err := os.ReadFile(path) assert.NoError(t, err) - defer func() { - if cerr := f.Close(); cerr != nil { - assert.NoError(t, cerr) - } - }() tazuna := v1.Tazuna{} - err = yaml.NewDecoder(f).Decode(&tazuna) + err = yaml.Unmarshal(data, &tazuna) assert.NoError(t, err) tags, err := r.ListTags(context.Background(), &tazuna, path) From 6e986cad71466e5c3377d77fb41c5bd923ef58d8 Mon Sep 17 00:00:00 2001 From: drumato <41734896+Drumato@users.noreply.github.com> Date: Sat, 16 May 2026 10:07:45 +0900 Subject: [PATCH 13/13] fix lint Signed-off-by: drumato <41734896+Drumato@users.noreply.github.com> --- cmd/tags.go | 14 +++++++++----- cmd/tags_test.go | 12 ++++++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/cmd/tags.go b/cmd/tags.go index 3c95005..62ea540 100644 --- a/cmd/tags.go +++ b/cmd/tags.go @@ -47,14 +47,13 @@ Examples: } filter := getTags(cmd) - printTagsFiltered(cmd.OutOrStdout(), tags, filter) - return nil + return printTagsFiltered(cmd.OutOrStdout(), tags, filter) }, } // printTagsFiltered writes the tag→names map to w. When filter is non-empty, // only the listed tag names are emitted; output is sorted for determinism. -func printTagsFiltered(w io.Writer, tags map[string][]string, filter []string) { +func printTagsFiltered(w io.Writer, tags map[string][]string, filter []string) error { want := tags if len(filter) > 0 { want = make(map[string][]string, len(filter)) @@ -70,11 +69,16 @@ func printTagsFiltered(w io.Writer, tags map[string][]string, filter []string) { } sort.Strings(keys) for _, tag := range keys { - fmt.Fprintf(w, "%s:\n", tag) + if _, err := fmt.Fprintf(w, "%s:\n", tag); err != nil { + return errors.WithStack(err) + } for _, name := range want[tag] { - fmt.Fprintf(w, "- %s\n", name) + if _, err := fmt.Fprintf(w, "- %s\n", name); err != nil { + return errors.WithStack(err) + } } } + return nil } func init() { diff --git a/cmd/tags_test.go b/cmd/tags_test.go index 9d59228..b478dd4 100644 --- a/cmd/tags_test.go +++ b/cmd/tags_test.go @@ -13,10 +13,12 @@ import ( func TestPrintTagsFiltered_NoFilter(t *testing.T) { t.Parallel() var buf bytes.Buffer - printTagsFiltered(&buf, map[string][]string{ + if err := printTagsFiltered(&buf, map[string][]string{ "frontend": {"web"}, "backend": {"api"}, - }, nil) + }, nil); err != nil { + t.Fatalf("printTagsFiltered: %v", err) + } got := buf.String() // keys are sorted, so backend appears before frontend if !strings.HasPrefix(got, "backend:\n- api\nfrontend:\n- web\n") { @@ -27,11 +29,13 @@ func TestPrintTagsFiltered_NoFilter(t *testing.T) { func TestPrintTagsFiltered_WithFilter(t *testing.T) { t.Parallel() var buf bytes.Buffer - printTagsFiltered(&buf, map[string][]string{ + if err := printTagsFiltered(&buf, map[string][]string{ "frontend": {"web"}, "backend": {"api"}, "infra": {"vpc"}, - }, []string{"frontend", "infra", "unknown"}) + }, []string{"frontend", "infra", "unknown"}); err != nil { + t.Fatalf("printTagsFiltered: %v", err) + } got := buf.String() if strings.Contains(got, "backend") { t.Errorf("filter leaked backend: %s", got)