diff --git a/api/v1/genesissecret_types.go b/api/v1/genesissecret_types.go index 97a0e4f..02e2b8a 100644 --- a/api/v1/genesissecret_types.go +++ b/api/v1/genesissecret_types.go @@ -1,47 +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"` -} - -func (GenesisSecret) GetKind() string { - return "GenesisSecret" -} -func (GenesisSecret) GetName() string { - return "" + Type string `json:"type"` } diff --git a/api/v1/genesissecret_types_test.go b/api/v1/genesissecret_types_test.go index e08742f..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) { @@ -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()) - } -} 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 7625623..acdacd3 100644 --- a/api/v1/tazuna_types.go +++ b/api/v1/tazuna_types.go @@ -1,8 +1,21 @@ package v1 +const ( + // TazunaAPIVersion は Tazuna リソースが取りうる apiVersion の正規値です。 + TazunaAPIVersion = "tazuna.pepabo.com/v1" + // TazunaKind は Tazuna リソースが取りうる kind の正規値です。 + TazunaKind = "Tazuna" +) + // Tazuna はtazuna applyの挙動を制御するルートリソースです type Tazuna struct { - Spec TazunaSpec `yaml:"spec"` + // APIVersion は Kubernetes manifest と同形式の TypeMeta フィールドです。 + // 設定する場合は TazunaAPIVersion と一致している必要があります。 + APIVersion string `json:"apiVersion,omitempty"` + // Kind は Kubernetes manifest と同形式の TypeMeta フィールドです。 + // 設定する場合は TazunaKind と一致している必要があります。 + Kind string `json:"kind,omitempty"` + Spec TazunaSpec `json:"spec"` } // ContextMatchMode はcontext_matchesの評価モードを定義します @@ -18,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 @@ -71,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 { @@ -104,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/apply.go b/cmd/apply.go index 523ab26..83076a7 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,22 @@ 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 - } + tags := getTags(cmd) - 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 +54,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.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 +70,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 @@ -113,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 7eb9d50..92496fc 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,22 @@ 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 - } + tags := getTags(cmd) - 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 } orasOpts, err := buildORASPullOptions(cmd) if err != nil { @@ -72,27 +47,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.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) } @@ -104,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/check.go b/cmd/check.go index f8eae2c..cd2e61e 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -2,17 +2,15 @@ package cmd import ( "fmt" - "io" - "log/slog" "os" "path/filepath" "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" + "sigs.k8s.io/yaml" ) var checkCmd = &cobra.Command{ @@ -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.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,19 +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) } - logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})) + logger, err := cliutil.NewLogger(cmd) + if err != nil { + return err + } 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) } @@ -86,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/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/destroy.go b/cmd/destroy.go index 84cb7aa..0e7b277 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,64 +30,35 @@ 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() - 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) - } - tags := []string{} - if v, err := cmd.Flags().GetStringSlice("tags"); err == nil { - tags = v + return err } + tags := getTags(cmd) orasOpts, err := buildORASPullOptions(cmd) if err != nil { return err } 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.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 +84,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 @@ -127,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 new file mode 100644 index 0000000..3070bfe --- /dev/null +++ b/cmd/flags_test.go @@ -0,0 +1,164 @@ +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: tagsCmd, + flags: []flagSpec{ + {name: "tags", defaultVal: "[]"}, + }, + }, + { + 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/internal/cliutil/cliutil.go b/cmd/internal/cliutil/cliutil.go new file mode 100644 index 0000000..d50a1a6 --- /dev/null +++ b/cmd/internal/cliutil/cliutil.go @@ -0,0 +1,68 @@ +// 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" + 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 +// 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 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) + } + tazuna := v1.Tazuna{} + if err := yaml.Unmarshal(data, &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/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)") } 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 cd0967a..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.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 3fab875..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.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 6a52905..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.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 0ca0860..62ea540 100644 --- a/cmd/tags.go +++ b/cmd/tags.go @@ -4,14 +4,13 @@ import ( "fmt" "io" "log/slog" - "os" + "sort" "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{ @@ -19,50 +18,70 @@ 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`, - RunE: func(cmd *cobra.Command, args []string) (err error) { + 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 { 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.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) } - for tag, relatedNames := range tags { - fmt.Printf("%s:\n", tag) - for _, name := range relatedNames { - fmt.Printf("- %s\n", name) + filter := getTags(cmd) + 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) error { + 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 } } - return nil - }, + } + keys := make([]string, 0, len(want)) + for k := range want { + keys = append(keys, k) + } + sort.Strings(keys) + for _, tag := range keys { + if _, err := fmt.Fprintf(w, "%s:\n", tag); err != nil { + return errors.WithStack(err) + } + for _, name := range want[tag] { + if _, err := fmt.Fprintf(w, "- %s\n", name); err != nil { + return errors.WithStack(err) + } + } + } + return nil } 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 new file mode 100644 index 0000000..b478dd4 --- /dev/null +++ b/cmd/tags_test.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPrintTagsFiltered_NoFilter(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + if err := printTagsFiltered(&buf, map[string][]string{ + "frontend": {"web"}, + "backend": {"api"}, + }, 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") { + t.Errorf("unexpected output:\n%s", got) + } +} + +func TestPrintTagsFiltered_WithFilter(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + if err := printTagsFiltered(&buf, map[string][]string{ + "frontend": {"web"}, + "backend": {"api"}, + "infra": {"vpc"}, + }, []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) + } + 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() { + _ = 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") + } +} 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/op/command_client.go b/pkg/op/command_client.go index 9dc33d7..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( @@ -19,7 +24,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 } @@ -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(). @@ -45,7 +56,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 } @@ -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(). @@ -69,7 +83,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/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/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 cc8c10f..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に保存し、 @@ -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( 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) 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) 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) + } +} 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 ||