diff --git a/flake.nix b/flake.nix index 2f5f42f..0b080db 100644 --- a/flake.nix +++ b/flake.nix @@ -26,7 +26,7 @@ buildGoModule' = pkgs.buildGoModule.override { inherit go; }; - deployahVendorHash = "sha256-6NnHWalqt/RRTxGgvTK73J87EyPt4N+IAZ7BMp+x6RA="; + deployahVendorHash = "sha256-T+jH1DfrvVfMYhKjotGhOEvLrbcQOt4HPQ/UlDC+jhQ="; inherit (pkgs) golangci-lint gopls; diff --git a/go.mod b/go.mod index 5e31609..d4e6145 100644 --- a/go.mod +++ b/go.mod @@ -12,11 +12,9 @@ require ( github.com/fatih/color v1.19.0 github.com/fluxcd/pkg/envsubst v1.8.0 github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/gonvenience/ytbx v1.5.0 github.com/google/go-containerregistry v0.22.1 github.com/google/renameio/v2 v2.0.2 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 - github.com/homeport/dyff v1.12.0 github.com/robfig/cron/v3 v3.0.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/spf13/cast v1.10.0 @@ -127,11 +125,6 @@ require ( github.com/go-openapi/swag/yamlutils v0.29.2 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gofrs/flock v0.13.0 // indirect - github.com/gonvenience/bunt v1.4.3 // indirect - github.com/gonvenience/idem v0.0.3 // indirect - github.com/gonvenience/neat v1.3.20 // indirect - github.com/gonvenience/term v1.0.5 // indirect - github.com/gonvenience/text v1.0.10 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/uuid v1.6.0 // indirect @@ -150,14 +143,12 @@ require ( github.com/lib/pq v1.12.3 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect - github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/miekg/dns v1.1.68 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -189,19 +180,16 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/rubenv/sql-migrate v1.8.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sergi/go-diff v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/tetratelabs/wazero v1.12.0 // indirect - github.com/texttheater/golang-levenshtein v1.0.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v1.0.0 // indirect diff --git a/go.sum b/go.sum index f44e771..c731a4b 100644 --- a/go.sum +++ b/go.sum @@ -254,18 +254,6 @@ github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/gonvenience/bunt v1.4.3 h1:MLd8YWu1Vl1tiL+XfXJvVA9kL71yQT0N+x7gXVH9H7w= -github.com/gonvenience/bunt v1.4.3/go.mod h1:ggA6odP6FNOh50mGxxytSSJTs2Ghy5Veq9wIVSbuoAw= -github.com/gonvenience/idem v0.0.3 h1:rZ2f17JU5GHa3b5M5R2fClz0dYN3EFGhHHGo3AZz/1U= -github.com/gonvenience/idem v0.0.3/go.mod h1:ChZ+RP8e30+uCBcCIzN/0di6lTO2PucjemgKfzQUQEw= -github.com/gonvenience/neat v1.3.20 h1:KdevSy5GLb3h1U5AYGw3NwW9FAAU2MWfrtsDHzYaKOg= -github.com/gonvenience/neat v1.3.20/go.mod h1:GbVes855L3QYFkDg9pnxHe/FQVsr1Tl+ME0fyOZO4Lg= -github.com/gonvenience/term v1.0.5 h1:PYfBH7FB1V+tuuJl4KYrqG/tzAOUnvTy8IFa9YqYrJY= -github.com/gonvenience/term v1.0.5/go.mod h1:CYvcU7H3nE6eOP0gvGfYz4BjGJzM1GeNp+fx4IBWKLs= -github.com/gonvenience/text v1.0.10 h1:QRqtC/KMk57K7y4jHi4HjLxf8u+tg+/tIRCS5afywNE= -github.com/gonvenience/text v1.0.10/go.mod h1:qO4aTZGAXbeW7eJXK+94nIc5Uumz8Q5DphOFZex6JHI= -github.com/gonvenience/ytbx v1.5.0 h1:6AbxnAWwyY+tMLEBENXC4m1j71Ubcv2+UaQmEA7rYv4= -github.com/gonvenience/ytbx v1.5.0/go.mod h1:zxRSqmJ2sHOH+XyYFAPhyb7y+xjnRSRHLcNta5Ybcws= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= @@ -301,8 +289,6 @@ github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvH github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/homeport/dyff v1.12.0 h1:1d4T2vdY0hYeWtAxjMLIX9bI8OijBfOuKH3wzfdYZT8= -github.com/homeport/dyff v1.12.0/go.mod h1:ArdUQcX099hp+uQ7pnimwU0Xgk2ba7E7nqdFv3WBRr8= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 h1:QixF8Mcbe87ET7pK/fPbBJ9GXFddmEY8yYMepzMzo30= @@ -315,11 +301,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= @@ -333,8 +316,6 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 h1:BXxTozrOU8zgC5dkpn3J6NTRdoP+hjok/e+ACr4Hibk= -github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3/go.mod h1:x1uk6vxTiVuNt6S5R2UYgdhpj3oKojXvOXauHZ7dEnI= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= @@ -349,8 +330,6 @@ github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= -github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= -github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= @@ -458,7 +437,6 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= @@ -467,8 +445,6 @@ github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZ github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= -github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U= -github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -479,8 +455,6 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo= -github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74/go.mod h1:RmMWU37GKR2s6pgrIEB4ixgpVCt/cf7dnJv3fuH1J1c= github.com/vladimirvivien/gexe v0.5.0 h1:AWBVaYnrTsGYBktXvcO0DfWPeSiZxn6mnQ5nvL+A1/A= github.com/vladimirvivien/gexe v0.5.0/go.mod h1:3gjgTqE2c0VyHnU5UOIwk7gyNzZDGulPb/DJPgcw64E= github.com/wI2L/jsondiff v0.7.1 h1:Fg9+yj+1/x3UtPBJhR91TKEzRkrEEWcAcLbg9dzEaNM= @@ -590,14 +564,12 @@ gopherly.dev/currus v0.8.1/go.mod h1:3RmR3SlftH9QVi81jbuOoA+WsmYOQ2GotX3Ck+Eha/Y gopherly.dev/termio v0.3.0 h1:BCpUbIL/0+LYke7d1i66TO4vcQ7YpuVCcCg2lCnvO1Y= gopherly.dev/termio v0.3.0/go.mod h1:T61iG9wYEezZCyDuoqEMyJnh6JSUU89drRZgdYqNcMo= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index adf2406..77e2602 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -21,8 +21,6 @@ import ( "github.com/stretchr/testify/assert" - "deployah.dev/deployah/internal/plan" - planCmd "deployah.dev/deployah/internal/cmd/plan" ) @@ -35,7 +33,6 @@ func TestExitCode(t *testing.T) { }{ {name: "nil", want: 0}, {name: "plan changes", err: fmt.Errorf("wrap: %w", planCmd.ErrChangesPresent), want: 2}, - {name: "legacy sentinel", err: fmt.Errorf("wrap: %w", plan.ErrChangesPresent), want: 1}, {name: "ordinary", err: errors.New("boom"), want: 1}, } for _, tc := range tests { diff --git a/internal/drift/client.go b/internal/drift/client.go deleted file mode 100644 index 8adf9f9..0000000 --- a/internal/drift/client.go +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package drift - -import ( - "context" - "fmt" - - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/discovery" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/rest" - "k8s.io/client-go/restmapper" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - memcached "k8s.io/client-go/discovery/cached/memory" - sigsyaml "sigs.k8s.io/yaml" -) - -// FieldManager is the field manager name every drift dry-run PATCH is sent -// with. It matches the field manager Deployah's real applies use, so a -// prediction reflects Deployah's own ownership, not a foreign manager's. -const FieldManager = "deployah" - -// forceOwnership is passed as [metav1.PatchOptions.Force] on every dry-run -// apply. Without it, a field another controller already owns (e.g. an HPA -// driving spec.replicas) would make the dry-run fail with a conflict. -var forceOwnership = true - -// Predictor predicts a resource's post-apply state via a server-side apply -// dry-run and fetches its current live state. [Client] is the production -// implementation; tests substitute a stub to exercise [ComputeDrift]'s -// subtraction logic without a cluster. -type Predictor interface { - // Predict returns the predicted and live YAML for the single resource - // described by resourceYAML. live is "" with a nil error when the - // resource does not exist yet (not a failure). - Predict(ctx context.Context, resourceYAML string) (predicted, live string, err error) -} - -// Client is the production [Predictor], talking to a real Kubernetes API -// server through a dynamic client and a discovery-backed REST mapper. -type Client struct { - dynamicClient dynamic.Interface - mapper meta.RESTMapper -} - -// NewClient builds a drift [Client] targeting the cluster described by cfg. -func NewClient(cfg *rest.Config) (*Client, error) { - dyn, err := dynamic.NewForConfig(cfg) - if err != nil { - return nil, fmt.Errorf("build dynamic client: %w", err) - } - disco, err := discovery.NewDiscoveryClientForConfig(cfg) - if err != nil { - return nil, fmt.Errorf("build discovery client: %w", err) - } - mapper := restmapper.NewDeferredDiscoveryRESTMapper(memcached.NewMemCacheClient(disco)) - return newClient(dyn, mapper), nil -} - -// newClient is the shared constructor behind [NewClient] (real clusters) -// and tests (a fake dynamic client paired with a static REST mapper). -func newClient(dyn dynamic.Interface, mapper meta.RESTMapper) *Client { - return &Client{dynamicClient: dyn, mapper: mapper} -} - -// Predict implements [Predictor]. -func (c *Client) Predict(ctx context.Context, resourceYAML string) (predicted, live string, err error) { - obj := &unstructured.Unstructured{} - if decodeErr := sigsyaml.Unmarshal([]byte(resourceYAML), &obj.Object); decodeErr != nil { - return "", "", fmt.Errorf("decode resource: %w", decodeErr) - } - gvk := obj.GroupVersionKind() - - mapping, err := c.mapper.RESTMapping(gvk.GroupKind(), gvk.Version) - if err != nil { - return "", "", fmt.Errorf("resolve resource mapping for %s %s: %w", gvk, obj.GetName(), err) - } - - var ri dynamic.ResourceInterface - if mapping.Scope.Name() == meta.RESTScopeNameNamespace { - ri = c.dynamicClient.Resource(mapping.Resource).Namespace(obj.GetNamespace()) - } else { - ri = c.dynamicClient.Resource(mapping.Resource) - } - - predictedObj, err := ri.Patch(ctx, obj.GetName(), types.ApplyPatchType, []byte(resourceYAML), metav1.PatchOptions{ - DryRun: []string{metav1.DryRunAll}, - FieldManager: FieldManager, - Force: &forceOwnership, - }) - if err != nil { - return "", "", fmt.Errorf("predict %s %q: %w", gvk.Kind, obj.GetName(), err) - } - - liveObj, err := ri.Get(ctx, obj.GetName(), metav1.GetOptions{}) - if apierrors.IsNotFound(err) { - predicted, err = toYAML(predictedObj) - if err != nil { - return "", "", err - } - return predicted, "", nil - } - if err != nil { - return "", "", fmt.Errorf("fetch live state of %s %q: %w", gvk.Kind, obj.GetName(), err) - } - - predicted, err = toYAML(predictedObj) - if err != nil { - return "", "", err - } - live, err = toYAML(liveObj) - if err != nil { - return "", "", err - } - return predicted, live, nil -} - -func toYAML(obj *unstructured.Unstructured) (string, error) { - b, err := sigsyaml.Marshal(obj.Object) - if err != nil { - return "", fmt.Errorf("encode resource to YAML: %w", err) - } - return string(b), nil -} diff --git a/internal/drift/client_test.go b/internal/drift/client_test.go deleted file mode 100644 index 6f46670..0000000 --- a/internal/drift/client_test.go +++ /dev/null @@ -1,282 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package drift - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/meta/testrestmapper" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/rest" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - dynamicfake "k8s.io/client-go/dynamic/fake" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - clienttesting "k8s.io/client-go/testing" - sigsyaml "sigs.k8s.io/yaml" -) - -const clientTestDeployment = ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: web - namespace: default -spec: - replicas: 2 -` - -// interceptApply installs a "patch" reactor that decodes the incoming apply -// body and returns it as-is as the "predicted" object without writing it -// back into the tracker, since [k8s.io/client-go/testing.ObjectTracker] -// (unlike a real API server) ignores metav1.PatchOptions.DryRun and would -// otherwise silently overwrite the seeded live object. -func interceptApply(client *dynamicfake.FakeDynamicClient, resource string) { - client.PrependReactor("patch", resource, func(action clienttesting.Action) (bool, runtime.Object, error) { - patchAction, ok := action.(clienttesting.PatchActionImpl) - if !ok { - return false, nil, nil - } - if patchAction.GetPatchType() != types.ApplyPatchType { - return false, nil, nil - } - obj := &unstructured.Unstructured{Object: map[string]any{}} - if err := sigsyaml.Unmarshal(patchAction.GetPatch(), &obj.Object); err != nil { - return true, nil, err - } - obj.SetName(patchAction.GetName()) - obj.SetNamespace(patchAction.GetNamespace()) - return true, obj, nil - }) -} - -func newTestClient(t *testing.T, liveObjects ...runtime.Object) *Client { - t.Helper() - fakeClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(), liveObjects...) - interceptApply(fakeClient, "deployments") - mapper := testrestmapper.TestOnlyStaticRESTMapper(clientgoscheme.Scheme) - return newClient(fakeClient, mapper) -} - -func unstructuredDeployment(name, namespace string, replicas int64) *unstructured.Unstructured { - return &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": map[string]any{ - "name": name, - "namespace": namespace, - }, - "spec": map[string]any{ - "replicas": replicas, - }, - }} -} - -// TestClientPredict_ExistingResource_ReturnsPredictedAndLive verifies -// [Client.Predict] resolves the resource's GVK through the REST mapper, -// sends a server-side apply dry-run PATCH, and fetches the live object -// separately, returning both as YAML. -func TestClientPredict_ExistingResource_ReturnsPredictedAndLive(t *testing.T) { - t.Parallel() - live := unstructuredDeployment("web", "default", 5) - c := newTestClient(t, live) - - predicted, liveYAML, err := c.Predict(t.Context(), clientTestDeployment) - require.NoError(t, err) - - var predictedDoc, liveDoc map[string]any - require.NoError(t, sigsyaml.Unmarshal([]byte(predicted), &predictedDoc)) - require.NoError(t, sigsyaml.Unmarshal([]byte(liveYAML), &liveDoc)) - - predictedSpec, ok := predictedDoc["spec"].(map[string]any) - require.True(t, ok) - liveSpec, ok := liveDoc["spec"].(map[string]any) - require.True(t, ok) - assert.InEpsilon(t, float64(2), predictedSpec["replicas"], 0, "predicted must reflect the desired manifest, not the live object") - assert.InEpsilon(t, float64(5), liveSpec["replicas"], 0, "live must reflect the pre-existing cluster object, unaffected by the dry-run patch") -} - -// TestClientPredict_ResourceNotFound_ReturnsEmptyLive verifies a resource -// that does not exist yet returns live="" with no error, matching the -// [Predictor] contract that [ComputeDrift] relies on to skip resources -// with no baseline to compare against. -func TestClientPredict_ResourceNotFound_ReturnsEmptyLive(t *testing.T) { - t.Parallel() - c := newTestClient(t) // no live objects seeded - - predicted, liveYAML, err := c.Predict(t.Context(), clientTestDeployment) - require.NoError(t, err) - assert.Empty(t, liveYAML) - assert.NotEmpty(t, predicted) -} - -// TestClientPredict_GetError_PropagatesAsError verifies a failure other -// than "not found" while fetching the live object (e.g. an RBAC denial) -// surfaces as an error rather than being treated as a missing resource. -func TestClientPredict_GetError_PropagatesAsError(t *testing.T) { - t.Parallel() - fakeClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()) - interceptApply(fakeClient, "deployments") - fakeClient.PrependReactor("get", "deployments", func(_ clienttesting.Action) (bool, runtime.Object, error) { - return true, nil, apierrors.NewForbidden( - schema.GroupResource{Group: "apps", Resource: "deployments"}, "web", errors.New("cannot get resource"), - ) - }) - mapper := testrestmapper.TestOnlyStaticRESTMapper(clientgoscheme.Scheme) - c := newClient(fakeClient, mapper) - - _, _, err := c.Predict(t.Context(), clientTestDeployment) - assert.Error(t, err) -} - -// TestNewClient verifies [NewClient] builds a [Client] from a REST config -// without contacting a server (dynamic and discovery client construction -// is purely local), and that an invalid config surfaces as an error. -func TestNewClient(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - cfg *rest.Config - wantErr bool - errContains string - }{ - { - name: "valid config builds a client", - cfg: &rest.Config{Host: "https://example.invalid:6443"}, - }, - { - name: "invalid config returns error", - // Username/password and a bearer token are mutually - // exclusive auth methods; client-go rejects the config - // before ever dialing a server. - cfg: &rest.Config{ - Host: "https://example.invalid:6443", - Username: "user", - BearerToken: "tok", - }, - wantErr: true, - errContains: "build dynamic client", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - c, err := NewClient(tt.cfg) - if tt.wantErr { - require.Error(t, err) - assert.Nil(t, c) - assert.Contains(t, err.Error(), tt.errContains) - return - } - require.NoError(t, err) - require.NotNil(t, c) - assert.NotNil(t, c.dynamicClient) - assert.NotNil(t, c.mapper) - }) - } -} - -// TestClientPredict_DecodeError verifies malformed resourceYAML surfaces a -// decode error instead of a panic or a silent no-op. -func TestClientPredict_DecodeError(t *testing.T) { - t.Parallel() - - c := newTestClient(t) - _, _, err := c.Predict(t.Context(), "not: valid: yaml: [") - require.Error(t, err) - assert.Contains(t, err.Error(), "decode resource") -} - -// TestClientPredict_UnmappedKind_ReturnsError verifies a resource whose -// GroupVersionKind the REST mapper does not recognize surfaces a clear -// "resolve resource mapping" error rather than a bare mapper error. -func TestClientPredict_UnmappedKind_ReturnsError(t *testing.T) { - t.Parallel() - - c := newTestClient(t) - unknownKindYAML := ` -apiVersion: totally.unknown/v1 -kind: FrobnicatorWidget -metadata: - name: x -` - _, _, err := c.Predict(t.Context(), unknownKindYAML) - require.Error(t, err) - assert.Contains(t, err.Error(), "resolve resource mapping") -} - -// TestToYAML_MarshalError verifies an object that cannot be marshaled to -// JSON (e.g. a channel value smuggled into the map) surfaces a wrapped -// error rather than a panic. -func TestToYAML_MarshalError(t *testing.T) { - t.Parallel() - - obj := &unstructured.Unstructured{Object: map[string]any{ - "badField": make(chan int), - }} - s, err := toYAML(obj) - require.Error(t, err) - assert.Empty(t, s) - assert.Contains(t, err.Error(), "encode resource to YAML") -} - -// TestClientPredict_DryRunAndForceOptionsAreSet verifies every predict -// PATCH is sent with the field manager, dry-run, and force-ownership -// options: without Force, a field another controller (e.g. an HPA driving -// spec.replicas) already owns would make the dry-run fail with a conflict -// instead of returning a prediction. -func TestClientPredict_DryRunAndForceOptionsAreSet(t *testing.T) { - t.Parallel() - fakeClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(), unstructuredDeployment("web", "default", 5)) - var captured *metav1.PatchOptions - fakeClient.PrependReactor("patch", "deployments", func(action clienttesting.Action) (bool, runtime.Object, error) { - patchAction, ok := action.(clienttesting.PatchActionImpl) - if !ok { - return false, nil, nil - } - opts := patchAction.GetPatchOptions() - captured = &opts - if patchAction.GetPatchType() != types.ApplyPatchType { - return false, nil, nil - } - obj := &unstructured.Unstructured{Object: map[string]any{}} - if err := sigsyaml.Unmarshal(patchAction.GetPatch(), &obj.Object); err != nil { - return true, nil, err - } - obj.SetName(patchAction.GetName()) - obj.SetNamespace(patchAction.GetNamespace()) - return true, obj, nil - }) - mapper := testrestmapper.TestOnlyStaticRESTMapper(clientgoscheme.Scheme) - c := newClient(fakeClient, mapper) - - _, _, err := c.Predict(t.Context(), clientTestDeployment) - require.NoError(t, err) - require.NotNil(t, captured) - assert.Equal(t, FieldManager, captured.FieldManager) - assert.Equal(t, []string{metav1.DryRunAll}, captured.DryRun) - require.NotNil(t, captured.Force) - assert.True(t, *captured.Force) -} diff --git a/internal/drift/doc.go b/internal/drift/doc.go deleted file mode 100644 index e0e984e..0000000 --- a/internal/drift/doc.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Package drift detects cluster drift: changes made to live resources -// outside of Deployah that Deployah's own spec-edit plan would not -// otherwise report. -// -// [Client] predicts each resource via a server-side apply dry-run and -// fetches its live state. [ComputeDrift] diffs the two with -// [deployah.dev/deployah/internal/plan.ComputeDiff] and subtracts every -// field path already explained by the spec-edit plan, so only changes the -// cluster picked up on its own remain. This backs `deployah plan --drift`: -// -// A = diff(render, last successful release) # the spec edit -// B = diff(predicted, live) # total delta -// drift = B minus paths(A) # per resource, per field path -package drift diff --git a/internal/drift/drift.go b/internal/drift/drift.go deleted file mode 100644 index 6d73a79..0000000 --- a/internal/drift/drift.go +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package drift - -import ( - "context" - "fmt" - - planengine "deployah.dev/deployah/internal/plan" -) - -// Result is the output of [ComputeDrift]. -type Result struct { - // Changes lists per-resource drift: fields that differ between the - // predicted apply and the resource's live state, but were not already - // part of the spec-edit diff in the plan passed to [ComputeDrift]. - Changes []planengine.Change - // Incomplete lists resource labels ("Kind/name" or - // "Kind/namespace/name") that drift could not be checked for, e.g. - // because of missing RBAC. A non-empty Incomplete means the plan is - // partial and must say so rather than silently omit those resources. - Incomplete []string -} - -// HasDrift reports whether r found any drift. -func (r *Result) HasDrift() bool { - return r != nil && len(r.Changes) > 0 -} - -// ComputeDrift predicts each resource in currentManifest via predictor and -// compares it against live state, subtracting field paths already explained -// by specPlan.Changes so only changes the cluster picked up outside of -// Deployah remain. On a fresh install (specPlan.Header.FreshInstall) it -// short-circuits to an empty, complete Result: there is no live baseline to -// compare against. -func ComputeDrift(ctx context.Context, predictor Predictor, specPlan *planengine.Plan, currentManifest string) (*Result, error) { - if specPlan.Header.FreshInstall { - return &Result{}, nil - } - - resources, err := planengine.SplitResources(currentManifest) - if err != nil { - return nil, fmt.Errorf("split rendered manifest: %w", err) - } - - explained := explainedPaths(specPlan) - adding := addedLabels(specPlan) - - result := &Result{} - for _, res := range resources { - if adding[res.Label] { - // specPlan already reports this as "+ add"; if it also exists - // live (e.g. an orphan from a failed release), every field - // would look like unexplained drift on a resource Deployah - // considers not-yet-existing. Skip it -- the add is the only - // signal that matters. - continue - } - predicted, live, predictErr := predictor.Predict(ctx, res.YAML) - if predictErr != nil { - // predictErr's text is surfaced verbatim: an RBAC denial from - // the API server already names the missing verb and resource, - // so there is nothing more useful to add here. - result.Incomplete = append(result.Incomplete, fmt.Sprintf("%s: %s", res.Label, predictErr)) - continue - } - if live == "" { - // Nothing live to compare against yet (e.g. a resource this - // same plan would add): no baseline, no drift. - continue - } - - total, diffErr := planengine.ComputeDiff(predicted, live) - if diffErr != nil { - result.Incomplete = append(result.Incomplete, fmt.Sprintf("%s: %s", res.Label, diffErr)) - continue - } - - if change := driftOnlyChange(total, explained[res.Label]); change != nil { - result.Changes = append(result.Changes, *change) - } - } - return result, nil -} - -// addedLabels indexes specPlan's changes by resource label, returning the -// set of labels whose Action is ActionAdd: resources the spec-edit diff -// already reports as new, for which [ComputeDrift] must not report -// per-field drift even if the resource already exists live. -func addedLabels(specPlan *planengine.Plan) map[string]bool { - out := make(map[string]bool) - for _, c := range specPlan.Changes { - if c.Action == planengine.ActionAdd { - out[resourceLabel(c.Kind, c.Namespace, c.Name)] = true - } - } - return out -} - -// explainedPaths indexes specPlan's changes by resource label so -// [ComputeDrift] can subtract, per resource, the field paths the spec edit -// already reports. -func explainedPaths(specPlan *planengine.Plan) map[string]map[string]struct{} { - out := make(map[string]map[string]struct{}, len(specPlan.Changes)) - for _, c := range specPlan.Changes { - paths := make(map[string]struct{}, len(c.Fields)) - for _, f := range c.Fields { - paths[f.Path] = struct{}{} - } - out[resourceLabel(c.Kind, c.Namespace, c.Name)] = paths - } - return out -} - -// driftOnlyChange filters total (the predicted-vs-live diff for one -// resource) down to fields absent from explained, returning nil when -// nothing remains. total always has at most one entry: predicted and live -// describe the same resource identity, so [planengine.ComputeDiff] can only -// ever report it as changed or unchanged, never added or destroyed. -func driftOnlyChange(total *planengine.Plan, explained map[string]struct{}) *planengine.Change { - if len(total.Changes) == 0 { - return nil - } - c := total.Changes[0] - remaining := make([]planengine.FieldDiff, 0, len(c.Fields)) - for _, f := range c.Fields { - if _, ok := explained[f.Path]; ok { - continue - } - remaining = append(remaining, f) - } - if len(remaining) == 0 { - return nil - } - c.Fields = remaining - return &c -} - -// resourceLabel matches the "Kind/name" / "Kind/namespace/name" format -// [planengine.SplitResources] uses for [planengine.ResourceYAML.Label]. -func resourceLabel(kind, namespace, name string) string { - if namespace == "" { - return fmt.Sprintf("%s/%s", kind, name) - } - return fmt.Sprintf("%s/%s/%s", kind, namespace, name) -} diff --git a/internal/drift/drift_test.go b/internal/drift/drift_test.go deleted file mode 100644 index 0002e95..0000000 --- a/internal/drift/drift_test.go +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package drift - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - planengine "deployah.dev/deployah/internal/plan" -) - -const driftDeployment = ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: web - namespace: default -spec: - replicas: 2 -` - -const driftDeploymentReplicas5 = ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: web - namespace: default -spec: - replicas: 5 -` - -const driftDeploymentReplicas3 = ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: web - namespace: default -spec: - replicas: 3 -` - -const driftDeploymentReplicas9 = ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: web - namespace: default -spec: - replicas: 9 -` - -// stubPredictor implements [Predictor] with per-resource canned responses, -// keyed by resource label ("Kind/namespace/name"). -type stubPredictor struct { - predicted map[string]string - live map[string]string - errs map[string]error - calls []string -} - -func (s *stubPredictor) Predict(_ context.Context, resourceYAML string) (predicted, live string, err error) { - resources, splitErr := planengine.SplitResources(resourceYAML) - if splitErr != nil || len(resources) != 1 { - return "", "", errors.New("stubPredictor: expected exactly one resource") - } - label := resources[0].Label - s.calls = append(s.calls, label) - - if e, ok := s.errs[label]; ok { - return "", "", e - } - return s.predicted[label], s.live[label], nil -} - -func specPlanWithChange(kind, namespace, name string, fields ...planengine.FieldDiff) *planengine.Plan { - return &planengine.Plan{ - Changes: []planengine.Change{ - {Action: planengine.ActionChange, Kind: kind, Namespace: namespace, Name: name, Fields: fields}, - }, - } -} - -// TestComputeDrift covers subtraction, ActionAdd skipping, incomplete -// marking, and the fresh-install short-circuit. -func TestComputeDrift(t *testing.T) { - t.Parallel() - - const label = "Deployment/default/web" - - tests := []struct { - name string - stub *stubPredictor - specPlan *planengine.Plan - wantDrift bool - wantCallsEmpty bool - wantIncomplete int - incompleteHas []string - check func(t *testing.T, result *Result) - }{ - { - name: "fresh install is no-op", - stub: &stubPredictor{}, - specPlan: &planengine.Plan{Header: planengine.Header{FreshInstall: true}}, - wantCallsEmpty: true, - }, - { - name: "unexplained field is drift", - stub: &stubPredictor{ - predicted: map[string]string{label: driftDeployment}, - live: map[string]string{label: driftDeploymentReplicas5}, - }, - specPlan: &planengine.Plan{}, - wantDrift: true, - check: func(t *testing.T, result *Result) { - t.Helper() - require.Len(t, result.Changes, 1) - assert.Equal(t, "Deployment", result.Changes[0].Kind) - require.Len(t, result.Changes[0].Fields, 1) - assert.Equal(t, "spec.replicas", result.Changes[0].Fields[0].Path) - assert.Equal(t, "2", result.Changes[0].Fields[0].Old) - assert.Equal(t, "5", result.Changes[0].Fields[0].New) - }, - }, - { - name: "identical predicted and live never double-reports", - stub: &stubPredictor{ - predicted: map[string]string{label: driftDeployment}, - live: map[string]string{label: driftDeployment}, - }, - specPlan: specPlanWithChange("Deployment", "default", "web", - planengine.FieldDiff{Path: "spec.replicas", ChangeKind: planengine.FieldChanged, Old: "1", New: "2"}, - ), - }, - { - name: "explained path is subtracted", - stub: &stubPredictor{ - predicted: map[string]string{label: driftDeployment}, - live: map[string]string{label: driftDeploymentReplicas3}, - }, - specPlan: specPlanWithChange("Deployment", "default", "web", - planengine.FieldDiff{Path: "spec.replicas", ChangeKind: planengine.FieldChanged, Old: "1", New: "2"}, - ), - }, - { - name: "ActionAdd with existing live skips field drift", - stub: &stubPredictor{ - predicted: map[string]string{label: driftDeployment}, - live: map[string]string{label: driftDeploymentReplicas9}, - }, - specPlan: &planengine.Plan{ - Changes: []planengine.Change{ - {Action: planengine.ActionAdd, Kind: "Deployment", Namespace: "default", Name: "web"}, - }, - }, - wantCallsEmpty: true, - }, - { - name: "no live baseline skips resource", - stub: &stubPredictor{ - predicted: map[string]string{label: driftDeployment}, - live: map[string]string{}, - }, - specPlan: &planengine.Plan{}, - }, - { - name: "predict error marks incomplete", - stub: &stubPredictor{ - errs: map[string]error{label: errors.New(`deployments.apps "web" is forbidden: User "ci" cannot patch resource`)}, - }, - specPlan: &planengine.Plan{}, - wantIncomplete: 1, - incompleteHas: []string{label, "forbidden"}, - }, - { - name: "malformed live YAML marks incomplete via diff error", - stub: &stubPredictor{ - predicted: map[string]string{label: driftDeployment}, - live: map[string]string{label: "not: valid: yaml: ["}, - }, - specPlan: &planengine.Plan{}, - wantIncomplete: 1, - incompleteHas: []string{label, "parsing"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - result, err := ComputeDrift(t.Context(), tt.stub, tt.specPlan, driftDeployment) - require.NoError(t, err) - assert.Equal(t, tt.wantDrift, result.HasDrift()) - if tt.wantCallsEmpty { - assert.Empty(t, tt.stub.calls) - } - require.Len(t, result.Incomplete, tt.wantIncomplete) - for _, s := range tt.incompleteHas { - require.NotEmpty(t, result.Incomplete) - assert.Contains(t, result.Incomplete[0], s) - } - if tt.check != nil { - tt.check(t, result) - } - }) - } -} - -// TestComputeDrift_MalformedManifest verifies a currentManifest that fails -// to split into resources surfaces a wrapped error instead of a panic or a -// silently empty result. -func TestComputeDrift_MalformedManifest(t *testing.T) { - t.Parallel() - - result, err := ComputeDrift(t.Context(), &stubPredictor{}, &planengine.Plan{}, "not: valid: yaml: [") - require.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "split rendered manifest") -} - -// TestResourceLabel verifies both the namespaced and cluster-scoped label -// formats, matching [planengine.SplitResources]'s [planengine.ResourceYAML.Label]. -func TestResourceLabel(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - kind string - namespace string - resource string - want string - }{ - {name: "namespaced resource", kind: "Deployment", namespace: "default", resource: "web", want: "Deployment/default/web"}, - {name: "cluster-scoped resource has no namespace segment", kind: "ClusterRole", namespace: "", resource: "admin", want: "ClusterRole/admin"}, - {name: "empty kind and name still format consistently", kind: "", namespace: "", resource: "", want: "/"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, tt.want, resourceLabel(tt.kind, tt.namespace, tt.resource)) - }) - } -} diff --git a/internal/plan/build.go b/internal/plan/build.go deleted file mode 100644 index 00f6d19..0000000 --- a/internal/plan/build.go +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "context" - "fmt" - "slices" - - "helm.sh/helm/v4/pkg/postrenderer" - - "deployah.dev/deployah/internal/extras" - "deployah.dev/deployah/internal/render" - "deployah.dev/deployah/internal/spec" - - v1 "helm.sh/helm/v4/pkg/release/v1" -) - -// BuildClient is the subset of -// [deployah.dev/deployah/internal/session.HelmClient] that [BuildPlan] -// needs: render the chart client-side and read release history. Defined -// narrowly, like [historyClient], so this package does not depend on -// internal/session and tests can inject a minimal fake. -type BuildClient interface { - historyClient - RenderManifests(ctx context.Context, resolved *spec.ResolvedSpec, postRenderer postrenderer.PostRenderer, crds []extras.RawFile) (*render.RenderResult, func(), error) -} - -// BuildPlan renders resolved and compares that render with the last -// successful Helm release. It returns the Plan, header included, and the -// render result. deployah plan uses this. -// -// Project, environment, and chart content come from resolved. -// -// Call the returned cleanup once, after you are done with -// result.ChartPath. Call it even when BuildPlan returns an error, if a -// chart was prepared. This is the same contract as -// [helm.Client.RenderManifests]. A non-nil postRenderer is passed through -// so extra manifests show up in the diff. crds are copied into that chart -// so Helm sees the same files deploy applies. -func BuildPlan(ctx context.Context, client BuildClient, clusterContext string, resolved *spec.ResolvedSpec, postRenderer postrenderer.PostRenderer, crds []extras.RawFile) (*Plan, *render.RenderResult, func(), error) { - if resolved == nil || resolved.Spec == nil { - return nil, nil, func() {}, fmt.Errorf("plan requires resolved spec; call spec.Resolve first") - } - manifest := resolved.Spec - environment := resolved.Env.Original - result, cleanup, err := client.RenderManifests(ctx, resolved, postRenderer, crds) - if cleanup == nil { - cleanup = func() {} - } - if err != nil { - return nil, nil, cleanup, fmt.Errorf("render manifests: %w", err) - } - - prevRelease, warning, err := LastSuccessfulRelease(ctx, client, manifest.Project, environment) - if err != nil { - return nil, nil, cleanup, fmt.Errorf("release history: %w", err) - } - - var previousManifest string - var previousHooks []*v1.Hook - revision := 0 - if prevRelease != nil { - previousManifest = prevRelease.Manifest - previousHooks = prevRelease.Hooks - revision = prevRelease.Version - } - - p, err := ComputeDiff(previousManifest, result.Manifest) - if err != nil { - return nil, nil, cleanup, fmt.Errorf("compute diff: %w", err) - } - p.HooksChanged = HooksChanged(previousHooks, result.Hooks) - p.Header = Header{ - Project: manifest.Project, - Environment: environment, - Release: result.ReleaseName, - Namespace: result.Namespace, - Context: clusterContext, - Revision: revision, - FreshInstall: prevRelease == nil, - Warning: warning, - } - p.Tasks, err = TasksFromSpec(manifest, environment, resolved) - if err != nil { - return nil, nil, cleanup, fmt.Errorf("tasks: %w", err) - } - - return p, result, cleanup, nil -} - -// TasksFromSpec builds the plan Tasks section from the spec. When resolved -// is nil, only tasks that apply to environment are included. -func TasksFromSpec(manifest *spec.Spec, environment string, resolved *spec.ResolvedSpec) ([]PlannedTask, error) { - tasks, err := spec.EffectiveTasks(manifest, environment, resolved) - if err != nil { - return nil, err - } - names := make([]string, 0, len(tasks)) - for name := range tasks { - names = append(names, name) - } - slices.Sort(names) - out := make([]PlannedTask, 0, len(names)) - for _, name := range names { - rt := tasks[name] - out = append(out, PlannedTask{ - Name: name, - On: plannedTaskOn(rt.Task.On), - Timeout: rt.Task.Timeout, - HookWeight: rt.HookWeight, - Manual: rt.Task.On == spec.TaskOnManual, - }) - } - return out, nil -} - -func plannedTaskOn(on spec.TaskOn) string { - switch on { - case spec.TaskOnPreDeploy: - return TaskOnPreDeploy - case spec.TaskOnPostDeploy: - return TaskOnPostDeploy - case spec.TaskOnManual: - return TaskOnManual - case spec.TaskOnSchedule: - return TaskOnSchedule - default: - return string(on) - } -} diff --git a/internal/plan/chart_crd.go b/internal/plan/chart_crd.go deleted file mode 100644 index 6deec78..0000000 --- a/internal/plan/chart_crd.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2026 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import "deployah.dev/deployah/internal/extras" - -// StampChartCRDs copies presentation identity from loaded CRD documents -// onto p and sets Helm lifecycle for this invocation. upgrade is Helm -// IsUpgrade, not FreshInstall: failed-only history is still an upgrade. -// skipCRDs is ignored when upgrade is true. -func StampChartCRDs(p *Plan, docs []extras.CRDDoc, upgrade, skipCRDs bool) { - if p == nil { - return - } - lifecycle := ChartCRDProcess - willProcess := true - switch { - case upgrade: - lifecycle = ChartCRDUpgrade - willProcess = false - case skipCRDs: - lifecycle = ChartCRDSkip - willProcess = false - } - p.ChartCRDs = make([]ChartCRD, 0, len(docs)) - for _, d := range docs { - p.ChartCRDs = append(p.ChartCRDs, ChartCRD{ - Source: extras.CRDDisplayPath(d.Path), - Index: d.Index, - Kind: d.Kind, - Name: d.Name, - Lifecycle: lifecycle, - WillProcess: willProcess, - YAML: string(d.YAML), - }) - } -} diff --git a/internal/plan/chart_crd_test.go b/internal/plan/chart_crd_test.go deleted file mode 100644 index 8cc4075..0000000 --- a/internal/plan/chart_crd_test.go +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright 2026 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "deployah.dev/deployah/internal/extras" -) - -func widgetCRDDoc(name, yamlBody string) extras.CRDDoc { - return extras.CRDDoc{ - Path: "/abs/.deployah/crds/widget.yaml", - Kind: "CustomResourceDefinition", - Name: name, - YAML: []byte(yamlBody), - } -} - -func TestStampChartCRDs_Lifecycle(t *testing.T) { - t.Parallel() - yamlBody := "kind: CustomResourceDefinition\nmetadata:\n name: widgets.example.com\n" - docs := []extras.CRDDoc{widgetCRDDoc("widgets.example.com", yamlBody)} - tests := []struct { - name string - upgrade bool - skipCRDs bool - wantLife ChartCRDLifecycle - wantProcess bool - wantChange bool - }{ - {name: "fresh install processes", wantLife: ChartCRDProcess, wantProcess: true, wantChange: true}, - {name: "fresh install skip", skipCRDs: true, wantLife: ChartCRDSkip, wantChange: false}, - {name: "upgrade ignores skip", upgrade: true, skipCRDs: true, wantLife: ChartCRDUpgrade, wantChange: false}, - {name: "upgrade does not process", upgrade: true, wantLife: ChartCRDUpgrade, wantChange: false}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - p := &Plan{} - StampChartCRDs(p, docs, tc.upgrade, tc.skipCRDs) - require.Len(t, p.ChartCRDs, 1) - got := p.ChartCRDs[0] - assert.Equal(t, "CustomResourceDefinition", got.Kind) - assert.Equal(t, "widgets.example.com", got.Name) - assert.Equal(t, ".deployah/crds/widget.yaml", got.Source) - assert.Equal(t, tc.wantLife, got.Lifecycle) - assert.Equal(t, tc.wantProcess, got.WillProcess) - assert.Equal(t, yamlBody, got.YAML) - assert.Equal(t, tc.wantChange, p.HasChanges()) - }) - } -} - -func TestHasChanges_ChartCRDsDoNotOverrideResourceChanges(t *testing.T) { - t.Parallel() - p := &Plan{Changes: []Change{{Action: ActionAdd, Kind: "ConfigMap", Name: "app"}}} - StampChartCRDs(p, []extras.CRDDoc{widgetCRDDoc("widgets.example.com", "kind: CustomResourceDefinition\n")}, true, false) - assert.True(t, p.HasChanges()) - assert.Equal(t, ChartCRDUpgrade, p.ChartCRDs[0].Lifecycle) -} - -func TestHasChanges_FreshInstallResourcesAndCRDs(t *testing.T) { - t.Parallel() - p := &Plan{Changes: []Change{{Action: ActionAdd, Kind: "ConfigMap", Name: "app"}}} - StampChartCRDs(p, []extras.CRDDoc{widgetCRDDoc("widgets.example.com", "kind: CustomResourceDefinition\n")}, false, false) - assert.True(t, p.HasChanges()) - assert.True(t, p.ChartCRDs[0].WillProcess) -} - -func TestRenderText_ChartCRDs(t *testing.T) { - t.Parallel() - yamlBody := "kind: CustomResourceDefinition\nmetadata:\n name: widgets.example.com\n" - docs := []extras.CRDDoc{widgetCRDDoc("widgets.example.com", yamlBody)} - tests := []struct { - name string - upgrade bool - skipCRDs bool - contains []string - notContains []string - }{ - { - name: "fresh install shows identity and yaml", - contains: []string{"+ CustomResourceDefinition/widgets.example.com", "Helm install will process this chart CRD", "kind: CustomResourceDefinition", "name: widgets.example.com"}, - notContains: []string{ - "will definitely be created", - "No changes.", - }, - }, - { - name: "skip does not show pending apply", - skipCRDs: true, - contains: []string{"CustomResourceDefinition/widgets.example.com", "install-time CRD processing disabled", "No changes."}, - notContains: []string{"+ CustomResourceDefinition/", "Helm install will process this chart CRD"}, - }, - { - name: "upgrade is not a kubernetes change", - upgrade: true, - contains: []string{"CustomResourceDefinition/widgets.example.com", "Helm upgrade will not process this chart CRD", "No changes."}, - notContains: []string{"+ CustomResourceDefinition/", yamlBody}, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - p := &Plan{Header: Header{Project: "web", Environment: "prod", Release: "web", FreshInstall: !tc.upgrade}} - StampChartCRDs(p, docs, tc.upgrade, tc.skipCRDs) - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - got := buf.String() - for _, s := range tc.contains { - assert.Contains(t, got, s) - } - for _, s := range tc.notContains { - assert.NotContains(t, got, s) - } - }) - } -} - -func TestRenderText_MultipleChartCRDs(t *testing.T) { - t.Parallel() - p := &Plan{Header: Header{FreshInstall: true}} - StampChartCRDs(p, []extras.CRDDoc{ - {Path: "types.yaml", Index: 0, Kind: "CustomResourceDefinition", Name: "one.example.com", YAML: []byte("kind: CustomResourceDefinition\nmetadata:\n name: one.example.com\n")}, - {Path: "types.yaml", Index: 1, Kind: "CustomResourceDefinition", Name: "two.example.com", YAML: []byte("kind: CustomResourceDefinition\nmetadata:\n name: two.example.com\n")}, - }, false, false) - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - got := buf.String() - assert.Contains(t, got, "+ CustomResourceDefinition/one.example.com") - assert.Contains(t, got, "+ CustomResourceDefinition/two.example.com") - assert.Contains(t, got, "name: one.example.com") - assert.Contains(t, got, "name: two.example.com") -} - -func TestRenderJSON_ChartCRDs(t *testing.T) { - t.Parallel() - docs := []extras.CRDDoc{widgetCRDDoc("widgets.example.com", "kind: CustomResourceDefinition\n")} - tests := []struct { - name string - upgrade bool - skipCRDs bool - wantLife string - wantProcess bool - }{ - {name: "fresh install", wantLife: "process", wantProcess: true}, - {name: "skip", skipCRDs: true, wantLife: "skip"}, - {name: "upgrade", upgrade: true, wantLife: "upgrade"}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - p := &Plan{Header: Header{Project: "web"}} - StampChartCRDs(p, docs, tc.upgrade, tc.skipCRDs) - var buf strings.Builder - require.NoError(t, RenderJSON(&buf, p)) - var doc map[string]any - require.NoError(t, json.Unmarshal([]byte(buf.String()), &doc)) - assert.Equal(t, "1.1", doc["format_version"]) - crds, ok := doc["chart_crds"].([]any) - require.True(t, ok) - require.Len(t, crds, 1) - entry, ok := crds[0].(map[string]any) - require.True(t, ok) - assert.Equal(t, "CustomResourceDefinition", entry["kind"]) - assert.Equal(t, "widgets.example.com", entry["name"]) - assert.Equal(t, tc.wantLife, entry["lifecycle"]) - assert.Equal(t, tc.wantProcess, entry["will_process"]) - assert.NotContains(t, entry, "action") - assert.NotContains(t, entry, "api_version") - assert.NotContains(t, buf.String(), "Helm install will process") - }) - } -} - -func TestRenderText_UnknownChartCRDLifecycleDoesNotClaimProcess(t *testing.T) { - t.Parallel() - p := &Plan{ChartCRDs: []ChartCRD{{ - Kind: "CustomResourceDefinition", - Name: "widgets.example.com", - }}} - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - got := buf.String() - assert.Contains(t, got, "CustomResourceDefinition/widgets.example.com") - assert.Contains(t, got, "chart CRD lifecycle is not known") - assert.NotContains(t, got, "Helm install will process this chart CRD") -} diff --git a/internal/plan/diff.go b/internal/plan/diff.go deleted file mode 100644 index 6d538f4..0000000 --- a/internal/plan/diff.go +++ /dev/null @@ -1,252 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "cmp" - "fmt" - "slices" - "strings" - - "github.com/gonvenience/ytbx" - "github.com/homeport/dyff/pkg/dyff" - - // TODO(#14): migrate native YAML to go.yaml.in/yaml/v4 once dyff/ytbx do. - yamlv3 "go.yaml.in/yaml/v3" - v1 "helm.sh/helm/v4/pkg/release/v1" -) - -// ComputeDiff parses previous and current as "---"-separated multi-document -// Kubernetes manifests and returns the resulting [Plan]'s Changes and -// Summary. previous may be the empty string (a fresh install), in which -// case every resource in current shows as an addition. The returned Plan's -// Header is always the zero value; the caller fills it in from what -// [LastSuccessfulRelease] and the render step already know. -func ComputeDiff(previous, current string) (*Plan, error) { - prevDocs, err := parseResources(previous) - if err != nil { - return nil, fmt.Errorf("parsing previous manifest: %w", err) - } - currDocs, err := parseResources(current) - if err != nil { - return nil, fmt.Errorf("parsing current manifest: %w", err) - } - - for _, d := range prevDocs { - normalizeResource(d.node) - } - for _, d := range currDocs { - normalizeResource(d.node) - } - - prevByKey := indexResources(prevDocs) - seenInCurrent := make(map[resourceKey]bool, len(currDocs)) - - p := &Plan{} - - for _, cd := range currDocs { - seenInCurrent[cd.key] = true - - pd, existed := prevByKey[cd.key] - if !existed { - p.Changes = append(p.Changes, changeFor(ActionAdd, cd.key, nil)) - p.Summary.Add++ - continue - } - - fields, diffErr := diffResource(pd.node, cd.node) - if diffErr != nil { - return nil, fmt.Errorf("diffing %s: %w", cd.key, diffErr) - } - if len(fields) == 0 { - continue // resource rendered identically: not a change - } - p.Changes = append(p.Changes, changeFor(ActionChange, cd.key, fields)) - p.Summary.Change++ - } - - for _, pd := range prevDocs { - if seenInCurrent[pd.key] { - continue - } - p.Changes = append(p.Changes, changeFor(ActionDestroy, pd.key, nil)) - p.Summary.Destroy++ - } - - slices.SortFunc(p.Changes, func(a, b Change) int { - if c := cmp.Compare(a.Kind, b.Kind); c != 0 { - return c - } - if c := cmp.Compare(a.Namespace, b.Namespace); c != 0 { - return c - } - return cmp.Compare(a.Name, b.Name) - }) - - return p, nil -} - -func changeFor(action Action, key resourceKey, fields []FieldDiff) Change { - return Change{ - Action: action, - Kind: key.Kind, - APIVersion: key.APIVersion, - Name: key.Name, - Namespace: key.Namespace, - Fields: fields, - } -} - -// diffResource runs dyff on a single matched resource pair (both nodes must -// describe the same Kubernetes object identity) and converts its report -// into [FieldDiff] entries. It returns an empty, non-nil-error slice when -// the two renders of the resource are equivalent. -func diffResource(prev, curr *yamlv3.Node) ([]FieldDiff, error) { - from := ytbx.InputFile{Documents: []*yamlv3.Node{prev}} - to := ytbx.InputFile{Documents: []*yamlv3.Node{curr}} - - report, err := dyff.CompareInputFiles(from, to) - if err != nil { - return nil, fmt.Errorf("comparing resource: %w", err) - } - - var fields []FieldDiff - for _, diff := range report.Diffs { - path := diff.Path.ToDotStyle() - segments := pathSegments(diff.Path) - for _, detail := range diff.Details { - switch detail.Kind { - case dyff.ADDITION: - fields = append(fields, expandMapDetail(FieldAdded, path, segments, detail.To)...) - - case dyff.REMOVAL: - fields = append(fields, expandMapDetail(FieldRemoved, path, segments, detail.From)...) - - case dyff.MODIFICATION: - fields = append(fields, FieldDiff{ - Path: path, - Segments: segments, - ChangeKind: FieldChanged, - Old: nodeToString(detail.From), - New: nodeToString(detail.To), - }) - - case dyff.ORDERCHANGE: - // A pure reordering of an already-identical list (e.g. env - // vars re-sorted by the template engine) has no effect on - // the applied resource, so it is not a meaningful change to - // show in the plan. - } - } - } - - return fields, nil -} - -// HooksChanged reports whether the set of Helm hooks differs between two -// releases (added, removed, or a hook whose manifest content changed). It -// is not part of ComputeDiff because Hooks live outside the "---"-separated -// manifest string ([deployah.dev/deployah/internal/render.RenderResult.Manifest] -// never includes them); the -// caller compares the two hook slices it already has from the previous -// release and the current render. -func HooksChanged(previous, current []*v1.Hook) bool { - if len(previous) != len(current) { - return true - } - - byName := func(hooks []*v1.Hook) map[string]string { - m := make(map[string]string, len(hooks)) - for _, h := range hooks { - m[h.Name] = h.Manifest - } - return m - } - - prev, curr := byName(previous), byName(current) - if len(prev) != len(curr) { - return true - } - for name, manifest := range prev { - if curr[name] != manifest { - return true - } - } - return false -} - -// expandMapDetail splits a whole-map ADDITION/REMOVAL into one FieldDiff per -// leaf field, so e.g. removing one key from an otherwise-unchanged map -// renders as its own line instead of a flattened block dump. Named list -// entries (e.g. a whole container added/removed) are exempt and stay a -// single dumped block; see writeYAMLValueBlock. -func expandMapDetail(kind FieldChangeKind, path string, segments []PathSegment, node *yamlv3.Node) []FieldDiff { - isNamedListEntry := len(segments) > 0 && segments[len(segments)-1].ListKey != "" - if isNamedListEntry || node == nil || node.Kind != yamlv3.MappingNode { - fd := FieldDiff{Path: path, Segments: segments, ChangeKind: kind} - if kind == FieldAdded { - fd.New = nodeToString(node) - } else { - fd.Old = nodeToString(node) - } - return []FieldDiff{fd} - } - - var fields []FieldDiff - for i := 0; i+1 < len(node.Content); i += 2 { - key := node.Content[i].Value - childSegments := append(append([]PathSegment{}, segments...), PathSegment{Name: key, Idx: -1}) - fields = append(fields, expandMapDetail(kind, path+"."+key, childSegments, node.Content[i+1])...) - } - return fields -} - -// pathSegments converts a dyff/ytbx structured path into [PathSegment]s. -// ytbx.PathElement disambiguates a plain map key from a named list-entry -// identifier as follows (see gonvenience/ytbx path.go): a named list entry -// has both Key (the identifying field, e.g. "name") and Name (the -// identifier value, e.g. "web") set; a plain map key has only Name set; -// an unmatched list entry has only Idx set (>= 0). -func pathSegments(path *ytbx.Path) []PathSegment { - segments := make([]PathSegment, 0, len(path.PathElements)) - for _, el := range path.PathElements { - switch { - case el.Key != "": - segments = append(segments, PathSegment{Name: el.Name, ListKey: el.Key, Idx: -1}) - case el.Name != "": - segments = append(segments, PathSegment{Name: el.Name, Idx: -1}) - default: - segments = append(segments, PathSegment{Idx: el.Idx}) - } - } - return segments -} - -// nodeToString renders a dyff detail value for display. Scalars render as -// their literal value; mapping and sequence nodes (a whole block added or -// removed, e.g. a new container) render as an indented YAML snippet. -func nodeToString(node *yamlv3.Node) string { - if node == nil { - return "" - } - if node.Kind == yamlv3.ScalarNode { - return node.Value - } - out, err := yamlv3.Marshal(node) - if err != nil { - return fmt.Sprintf("", err) - } - return strings.TrimRight(string(out), "\n") -} diff --git a/internal/plan/diff_test.go b/internal/plan/diff_test.go deleted file mode 100644 index d63227d..0000000 --- a/internal/plan/diff_test.go +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const deploymentV1 = ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: web - namespace: default -spec: - replicas: 2 - template: - spec: - containers: - - name: web - image: myapp:v1.2 -` - -const deploymentV2 = ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: web - namespace: default -spec: - replicas: 2 - template: - spec: - containers: - - name: web - image: myapp:v1.3 -` - -const configMap = ` -apiVersion: v1 -kind: ConfigMap -metadata: - name: web-config - namespace: default -data: - key: value -` - -const legacySidecar = ` -apiVersion: v1 -kind: Service -metadata: - name: legacy-sidecar - namespace: default -spec: - ports: - - port: 80 -` - -const deploymentWithNoise = ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: web - namespace: default - resourceVersion: "12345" - uid: abc-123 - generation: 3 - creationTimestamp: "2024-01-01T00:00:00Z" - managedFields: - - manager: kubectl -status: - replicas: 2 -spec: - replicas: 2 - template: - spec: - containers: - - name: web - image: myapp:v1.2 -` - -// TestComputeDiff covers add/change/destroy, noise stripping, and sort order. -func TestComputeDiff(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - previous string - current string - wantSummary Summary - wantLen int - wantHasChanges *bool - check func(t *testing.T, p *Plan) - }{ - { - name: "fresh install", - previous: "", - current: deploymentV1 + "---\n" + configMap, - wantSummary: Summary{Add: 2}, - wantLen: 2, - check: func(t *testing.T, p *Plan) { - t.Helper() - for _, c := range p.Changes { - assert.Equal(t, ActionAdd, c.Action) - assert.Empty(t, c.Fields) - } - }, - }, - { - name: "image bump", - previous: deploymentV1, - current: deploymentV2, - wantSummary: Summary{Change: 1}, - wantLen: 1, - check: func(t *testing.T, p *Plan) { - t.Helper() - change := p.Changes[0] - assert.Equal(t, ActionChange, change.Action) - assert.Equal(t, "Deployment", change.Kind) - assert.Equal(t, "web", change.Name) - require.Len(t, change.Fields, 1) - assert.Equal(t, "spec.template.spec.containers.web.image", change.Fields[0].Path) - assert.Equal(t, FieldChanged, change.Fields[0].ChangeKind) - assert.Equal(t, "myapp:v1.2", change.Fields[0].Old) - assert.Equal(t, "myapp:v1.3", change.Fields[0].New) - }, - }, - { - name: "resource added", - previous: deploymentV1, - current: deploymentV1 + "---\n" + configMap, - wantSummary: Summary{Add: 1}, - wantLen: 1, - check: func(t *testing.T, p *Plan) { - t.Helper() - assert.Equal(t, ActionAdd, p.Changes[0].Action) - assert.Equal(t, "ConfigMap", p.Changes[0].Kind) - assert.Equal(t, "web-config", p.Changes[0].Name) - }, - }, - { - name: "resource removed", - previous: deploymentV1 + "---\n" + legacySidecar, - current: deploymentV1, - wantSummary: Summary{Destroy: 1}, - wantLen: 1, - check: func(t *testing.T, p *Plan) { - t.Helper() - assert.Equal(t, ActionDestroy, p.Changes[0].Action) - assert.Equal(t, "Service", p.Changes[0].Kind) - assert.Equal(t, "legacy-sidecar", p.Changes[0].Name) - }, - }, - { - name: "no changes", - previous: deploymentV1, - current: deploymentV1, - wantSummary: Summary{}, - wantLen: 0, - wantHasChanges: new(false), - }, - { - name: "noise fields ignored", - previous: deploymentWithNoise, - current: deploymentV1, - wantSummary: Summary{}, - wantLen: 0, - }, - { - name: "mixed changes sorted by kind then name", - previous: deploymentV1 + "---\n" + legacySidecar, - current: deploymentV2 + "---\n" + configMap, - wantSummary: Summary{Add: 1, Change: 1, Destroy: 1}, - wantLen: 3, - check: func(t *testing.T, p *Plan) { - t.Helper() - assert.Equal(t, "ConfigMap", p.Changes[0].Kind) - assert.Equal(t, ActionAdd, p.Changes[0].Action) - assert.Equal(t, "Deployment", p.Changes[1].Kind) - assert.Equal(t, ActionChange, p.Changes[1].Action) - assert.Equal(t, "Service", p.Changes[2].Kind) - assert.Equal(t, ActionDestroy, p.Changes[2].Action) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(tt.previous, tt.current) - require.NoError(t, err) - assert.Equal(t, tt.wantSummary, p.Summary) - require.Len(t, p.Changes, tt.wantLen) - if tt.wantHasChanges != nil { - assert.Equal(t, *tt.wantHasChanges, p.HasChanges()) - } - if tt.check != nil { - tt.check(t, p) - } - }) - } -} diff --git a/internal/plan/doc.go b/internal/plan/doc.go index 7c6a1f3..3947448 100644 --- a/internal/plan/doc.go +++ b/internal/plan/doc.go @@ -20,20 +20,15 @@ // [SemanticBuildClient.RenderManifestsWithPrep]. It picks install, // upgrade, or none from the Helm operation and from comparing the // previous release with the render and its hooks. Resource changes -// compare the previous manifest with the rendered Desired manifest. -// Discovery supplies scope only. An existing release also reads Live -// with GET and LIST to compute Drift. Chart CRDs pass through to Helm. -// The result is a [deployah.dev/deployah/internal/plan/semantic.Plan]. -// Those types live in plan/semantic. Their rendering lives in plan/view. +// are Previous to Desired. [RESTMapper] supplies scope only. An +// existing release reads Live through [LiveReader] with GET and LIST, +// so Drift is Previous to Live. Chart CRD lifecycle comes from the +// loaded documents, not from object diffs. The result is a +// [deployah.dev/deployah/internal/plan/semantic.Plan]. Those types +// live in plan/semantic. Their rendering lives in plan/view. // -// [BuildPlan] diffs the rendered manifest against the last successful -// Helm release. [ComputeDiff] is the older diff. It parses two rendered -// manifests, matches by apiVersion, kind, namespace, and name, and runs -// [github.com/homeport/dyff] on resources present on both sides. [Plan] -// is that model. [RenderText] prints it, and [NewJSONDocument] encodes -// it as JSON. [DeploymentIntent] records resize and hostname flags. It -// does not decide whether deploy runs Helm. [BuildPlan] and [ComputeDiff] -// remain while callers move to the semantic plan. +// [LastSuccessfulRelease] remains for deploy hostname and workload +// guards. [BuildSemanticPlan] does not use it. // // Chart rendering is on [deployah.dev/deployah/internal/helm.Client]. // `deployah plan` and `deployah deploy` share it. diff --git a/internal/plan/format_json.go b/internal/plan/format_json.go deleted file mode 100644 index e157ccc..0000000 --- a/internal/plan/format_json.go +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "encoding/json" - "fmt" - "io" -) - -// jsonFormatVersion is the schema version emitted by [NewJSONDocument]. Bump -// it, and document the change, whenever a field is added, removed, or -// changes meaning. 1.1 adds chart_crds. Two deliberate omissions from 1.0 -// (no next revision, no spec-vocabulary path) stay. -const jsonFormatVersion = "1.1" - -// JSONDocument is the "--output json" wire format for a [Plan] -// (format_version "1.1"). Field names use snake_case. -type JSONDocument struct { - FormatVersion string `json:"format_version"` - Project string `json:"project"` - Environment string `json:"environment"` - Release string `json:"release"` - Namespace string `json:"namespace"` - Context string `json:"context"` - // Revision is null when FreshInstall is true: there is no current - // revision to report yet. - Revision *int `json:"revision"` - FreshInstall bool `json:"fresh_install"` - Warning string `json:"warning,omitempty"` - Changes []JSONChange `json:"changes"` - // HooksChanged is true when Helm hooks changed without a matching entry - // in Changes, so a hook-only change still explains a non-zero - // --detailed-exitcode against an otherwise-empty Changes/Summary. - HooksChanged bool `json:"hooks_changed,omitempty"` - Summary JSONSummary `json:"summary"` - // Drift and DriftIncomplete are omitted when there is nothing to report - // (either --drift wasn't requested, or it found nothing); the schema - // does not distinguish those two cases. - Drift []JSONChange `json:"drift,omitempty"` - DriftIncomplete []string `json:"drift_incomplete,omitempty"` - Tasks []JSONTask `json:"tasks,omitempty"` - FirstInstallNote string `json:"first_install_note,omitempty"` - ChartCRDs []JSONChartCRD `json:"chart_crds,omitempty"` -} - -// JSONChartCRD is one entry in [JSONDocument.ChartCRDs]. It is Helm -// chart-CRD lifecycle, not a predicted Kubernetes create or update. -type JSONChartCRD struct { - Source string `json:"source"` - Index int `json:"index"` - Kind string `json:"kind"` - Name string `json:"name"` - Lifecycle string `json:"lifecycle"` - WillProcess bool `json:"will_process"` -} - -// JSONTask is one entry in [JSONDocument.Tasks]. -type JSONTask struct { - Name string `json:"name"` - On string `json:"on"` - Timeout string `json:"timeout,omitempty"` - HookWeight int `json:"hook_weight"` - Manual bool `json:"manual,omitempty"` -} - -// JSONChange is one entry in [JSONDocument.Changes]. -type JSONChange struct { - Action Action `json:"action"` - Kind string `json:"kind"` - APIVersion string `json:"api_version"` - Name string `json:"name"` - Namespace string `json:"namespace"` - Fields []JSONField `json:"fields"` -} - -// JSONField is one entry in [JSONChange.Fields]. A masked field omits Old -// and New and carries Change (the [FieldChangeKind] as a string) instead; -// an unmasked field carries Old/New and omits Masked and Change. -type JSONField struct { - Path string `json:"path"` - Old string `json:"old,omitempty"` - New string `json:"new,omitempty"` - Masked bool `json:"masked,omitempty"` - Change string `json:"change,omitempty"` -} - -// JSONSummary is [JSONDocument.Summary]. -type JSONSummary struct { - Add int `json:"add"` - Change int `json:"change"` - Destroy int `json:"destroy"` -} - -// NewJSONDocument converts p into the format_version "1.1" JSON document. -// It masks secret field values unconditionally (calling [ApplyMasking] is -// safe to repeat): JSON output ignores --show-secrets by design, so a CI -// job can pipe it anywhere without a credential-leak review. -func NewJSONDocument(p *Plan) *JSONDocument { - ApplyMasking(p) - - doc := &JSONDocument{ - FormatVersion: jsonFormatVersion, - Project: p.Header.Project, - Environment: p.Header.Environment, - Release: p.Header.Release, - Namespace: p.Header.Namespace, - Context: p.Header.Context, - FreshInstall: p.Header.FreshInstall, - Warning: p.Header.Warning, - Changes: make([]JSONChange, 0, len(p.Changes)), - HooksChanged: p.HooksChanged, - Summary: JSONSummary{ - Add: p.Summary.Add, - Change: p.Summary.Change, - Destroy: p.Summary.Destroy, - }, - } - if !p.Header.FreshInstall && p.Header.Revision > 0 { - revision := p.Header.Revision - doc.Revision = &revision - } - - for _, c := range p.Changes { - doc.Changes = append(doc.Changes, toJSONChange(c)) - } - - for _, c := range p.Drift { - doc.Drift = append(doc.Drift, toJSONChange(c)) - } - doc.DriftIncomplete = p.DriftIncomplete - - for _, task := range p.Tasks { - doc.Tasks = append(doc.Tasks, JSONTask(task)) - } - doc.FirstInstallNote = p.FirstInstallTaskNote() - for _, crd := range p.ChartCRDs { - doc.ChartCRDs = append(doc.ChartCRDs, JSONChartCRD{ - Source: crd.Source, - Index: crd.Index, - Kind: crd.Kind, - Name: crd.Name, - Lifecycle: string(crd.Lifecycle), - WillProcess: crd.WillProcess, - }) - } - - return doc -} - -// toJSONChange converts one [Change] to its JSON wire representation, -// applying the same masking rule [NewJSONDocument] uses for ordinary -// changes. -func toJSONChange(c Change) JSONChange { - jc := JSONChange{ - Action: c.Action, - Kind: c.Kind, - APIVersion: c.APIVersion, - Name: c.Name, - Namespace: c.Namespace, - Fields: make([]JSONField, 0, len(c.Fields)), - } - for _, f := range c.Fields { - if f.Masked { - jc.Fields = append(jc.Fields, JSONField{ - Path: f.Path, - Masked: true, - Change: string(f.ChangeKind), - }) - continue - } - jc.Fields = append(jc.Fields, JSONField{ - Path: f.Path, - Old: f.Old, - New: f.New, - }) - } - return jc -} - -// RenderJSON writes p to w as pretty-printed format_version "1.1" JSON; see -// [NewJSONDocument]. -func RenderJSON(w io.Writer, p *Plan) error { - if p == nil { - return fmt.Errorf("plan is nil") - } - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(NewJSONDocument(p)) -} diff --git a/internal/plan/format_json_test.go b/internal/plan/format_json_test.go deleted file mode 100644 index 14ef7de..0000000 --- a/internal/plan/format_json_test.go +++ /dev/null @@ -1,359 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestRenderJSON_HeaderAndMixedChanges covers the named case. -func TestRenderJSON_HeaderAndMixedChanges(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1+"---\n"+legacySidecar, deploymentV2+"---\n"+configMap) - require.NoError(t, err) - p.Header = Header{ - Project: "web", - Environment: "production", - Release: "web-production", - Namespace: "default", - Context: "prod-eks-us-east-1", - Revision: 7, - } - - var buf strings.Builder - require.NoError(t, RenderJSON(&buf, p)) - - var doc map[string]any - require.NoError(t, json.Unmarshal([]byte(buf.String()), &doc)) - - assert.Equal(t, "1.1", doc["format_version"]) - assert.Equal(t, "web", doc["project"]) - assert.Equal(t, "production", doc["environment"]) - assert.Equal(t, "web-production", doc["release"]) - assert.Equal(t, "default", doc["namespace"]) - assert.Equal(t, "prod-eks-us-east-1", doc["context"]) - assert.InEpsilon(t, float64(7), doc["revision"], 0) - assert.Equal(t, false, doc["fresh_install"]) - - summary, ok := doc["summary"].(map[string]any) - require.True(t, ok) - assert.InEpsilon(t, float64(1), summary["add"], 0) - assert.InEpsilon(t, float64(1), summary["change"], 0) - assert.InEpsilon(t, float64(1), summary["destroy"], 0) - - changes, ok := doc["changes"].([]any) - require.True(t, ok) - require.Len(t, changes, 3) - - byKind := map[string]map[string]any{} - for _, raw := range changes { - c, changeOK := raw.(map[string]any) - require.True(t, changeOK) - kind, kindOK := c["kind"].(string) - require.True(t, kindOK) - byKind[kind] = c - } - - deployment := byKind["Deployment"] - require.NotNil(t, deployment) - assert.Equal(t, "change", deployment["action"]) - assert.Equal(t, "apps/v1", deployment["api_version"]) - assert.Equal(t, "web", deployment["name"]) - assert.Equal(t, "default", deployment["namespace"]) - fields, ok := deployment["fields"].([]any) - require.True(t, ok) - require.Len(t, fields, 1) - field, fieldOK := fields[0].(map[string]any) - require.True(t, fieldOK) - assert.Equal(t, "spec.template.spec.containers.web.image", field["path"]) - assert.Equal(t, "myapp:v1.2", field["old"]) - assert.Equal(t, "myapp:v1.3", field["new"]) - assert.NotContains(t, field, "masked") - assert.NotContains(t, field, "change") - - configMapChange := byKind["ConfigMap"] - require.NotNil(t, configMapChange) - assert.Equal(t, "add", configMapChange["action"]) - assert.Empty(t, configMapChange["fields"]) - - serviceChange := byKind["Service"] - require.NotNil(t, serviceChange) - assert.Equal(t, "destroy", serviceChange["action"]) - assert.Empty(t, serviceChange["fields"]) -} - -// TestRenderJSON_HooksChangedField verifies a hook-only change (no -// resource-level Changes at all) is still visible in the JSON document, so -// a CI consumer parsing `--output json --detailed-exitcode` output can -// explain a non-zero exit code against an otherwise-empty changes/summary. -func TestRenderJSON_HooksChangedField(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV1) - require.NoError(t, err) - p.HooksChanged = true - p.Header = Header{Release: "web-production"} - require.True(t, p.HasChanges(), "a hook-only change must still count as a change") - - var buf strings.Builder - require.NoError(t, RenderJSON(&buf, p)) - - var doc map[string]any - require.NoError(t, json.Unmarshal([]byte(buf.String()), &doc)) - - assert.Empty(t, doc["changes"], "manifest is unchanged: no resource-level changes") - assert.Equal(t, true, doc["hooks_changed"], "hooks_changed must surface even when changes is empty") -} - -// TestRenderJSON_HooksChangedOmittedWhenFalse verifies the common case (no -// hook change) keeps the field out of the document entirely (omitempty), -// matching the Warning field's convention. -func TestRenderJSON_HooksChangedOmittedWhenFalse(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV1) - require.NoError(t, err) - p.Header = Header{Release: "web-production"} - - var buf strings.Builder - require.NoError(t, RenderJSON(&buf, p)) - - var doc map[string]any - require.NoError(t, json.Unmarshal([]byte(buf.String()), &doc)) - - assert.NotContains(t, doc, "hooks_changed") -} - -// TestRenderJSON_FreshInstallRevisionIsNull covers the named case. -func TestRenderJSON_FreshInstallRevisionIsNull(t *testing.T) { - t.Parallel() - p, err := ComputeDiff("", deploymentV1) - require.NoError(t, err) - p.Header = Header{FreshInstall: true} - - var buf strings.Builder - require.NoError(t, RenderJSON(&buf, p)) - - var doc map[string]any - require.NoError(t, json.Unmarshal([]byte(buf.String()), &doc)) - - assert.Nil(t, doc["revision"]) - assert.Contains(t, string(buf.String()), `"revision": null`) - assert.Equal(t, true, doc["fresh_install"]) -} - -// TestRenderJSON_MaskedFieldOmitsOldAndNew covers the named case. -func TestRenderJSON_MaskedFieldOmitsOldAndNew(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(secretV1, secretV2) - require.NoError(t, err) - - var buf strings.Builder - require.NoError(t, RenderJSON(&buf, p)) - - raw := buf.String() - assert.NotContains(t, raw, "old-password") - assert.NotContains(t, raw, "new-password") - assert.Contains(t, raw, `"masked": true`) - assert.Contains(t, raw, `"change": "changed"`) - - var doc map[string]any - require.NoError(t, json.Unmarshal([]byte(raw), &doc)) - changes, changesOK := doc["changes"].([]any) - require.True(t, changesOK) - require.Len(t, changes, 1) - change, changeOK := changes[0].(map[string]any) - require.True(t, changeOK) - fields, fieldsOK := change["fields"].([]any) - require.True(t, fieldsOK) - for _, fieldRaw := range fields { - f, fieldOK := fieldRaw.(map[string]any) - require.True(t, fieldOK) - assert.NotContains(t, f, "old") - assert.NotContains(t, f, "new") - } -} - -// TestRenderJSON_WarningOmittedWhenEmpty covers the named case. -func TestRenderJSON_WarningOmittedWhenEmpty(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV1) - require.NoError(t, err) - - var buf strings.Builder - require.NoError(t, RenderJSON(&buf, p)) - - assert.NotContains(t, buf.String(), "warning") -} - -// TestRenderJSON_DriftOmittedWhenNotChecked verifies a plan rendered -// without --drift carries no "drift" key at all. -func TestRenderJSON_DriftOmittedWhenNotChecked(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV1) - require.NoError(t, err) - - var buf strings.Builder - require.NoError(t, RenderJSON(&buf, p)) - - var doc map[string]any - require.NoError(t, json.Unmarshal([]byte(buf.String()), &doc)) - assert.NotContains(t, doc, "drift") - assert.NotContains(t, doc, "drift_incomplete") -} - -// TestRenderJSON_DriftChangesAndIncomplete verifies drift changes and -// incomplete resources both serialize, and that a masked drift field omits -// old/new the same way an ordinary masked change does. -func TestRenderJSON_DriftChangesAndIncomplete(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV1) - require.NoError(t, err) - p.DriftChecked = true - p.Drift = []Change{ - { - Action: ActionChange, - Kind: "Deployment", - Name: "web", - Fields: []FieldDiff{ - {Path: "spec.replicas", ChangeKind: FieldChanged, Old: "2", New: "5"}, - }, - }, - { - Action: ActionChange, - Kind: "Secret", - Name: "web-secret", - Fields: []FieldDiff{ - {Path: "data.password", ChangeKind: FieldChanged, Old: "old", New: "new"}, - }, - }, - } - p.DriftIncomplete = []string{"ConfigMap/default/web-config"} - - var buf strings.Builder - require.NoError(t, RenderJSON(&buf, p)) - - var doc map[string]any - require.NoError(t, json.Unmarshal([]byte(buf.String()), &doc)) - - drift, ok := doc["drift"].([]any) - require.True(t, ok) - require.Len(t, drift, 2) - - byKind := map[string]map[string]any{} - for _, raw := range drift { - c, isMap := raw.(map[string]any) - require.True(t, isMap) - kind, isString := c["kind"].(string) - require.True(t, isString) - byKind[kind] = c - } - - deploymentDrift := byKind["Deployment"] - require.NotNil(t, deploymentDrift) - deploymentFieldList, ok := deploymentDrift["fields"].([]any) - require.True(t, ok) - require.NotEmpty(t, deploymentFieldList) - fields, ok := deploymentFieldList[0].(map[string]any) - require.True(t, ok) - assert.Equal(t, "2", fields["old"]) - assert.Equal(t, "5", fields["new"]) - - secretDrift := byKind["Secret"] - require.NotNil(t, secretDrift) - secretFieldList, ok := secretDrift["fields"].([]any) - require.True(t, ok) - require.NotEmpty(t, secretFieldList) - secretFields, ok := secretFieldList[0].(map[string]any) - require.True(t, ok) - assert.NotContains(t, secretFields, "old", "drift under a Secret's data block must still be masked") - assert.NotContains(t, secretFields, "new") - - incomplete, ok := doc["drift_incomplete"].([]any) - require.True(t, ok) - assert.Equal(t, []any{"ConfigMap/default/web-config"}, incomplete) -} - -func TestRenderJSON_TasksSection(t *testing.T) { - t.Parallel() - - note := (&Plan{ - Header: Header{FreshInstall: true}, - Tasks: []PlannedTask{{On: TaskOnPreDeploy}}, - }).FirstInstallTaskNote() - tests := []struct { - name string - plan *Plan - want []JSONTask - wantNote string - wantRaw []string - }{ - { - name: "fresh install with preDeploy", - plan: &Plan{ - Header: Header{ - Project: "shop", - Environment: "dev", - Release: "shop-dev", - FreshInstall: true, - }, - Tasks: []PlannedTask{ - {Name: "migrate", On: TaskOnPreDeploy, Timeout: "5m", HookWeight: 0}, - {Name: "backfill", On: TaskOnManual, Manual: true}, - }, - }, - want: []JSONTask{ - {Name: "migrate", On: TaskOnPreDeploy, Timeout: "5m", HookWeight: 0}, - {Name: "backfill", On: TaskOnManual, Manual: true}, - }, - wantNote: note, - wantRaw: []string{`"hook_weight": 0`}, - }, - { - name: "upgrade omits first install note", - plan: &Plan{ - Header: Header{FreshInstall: false}, - Tasks: []PlannedTask{{Name: "migrate", On: TaskOnPreDeploy, HookWeight: 0}}, - }, - want: []JSONTask{{Name: "migrate", On: TaskOnPreDeploy, HookWeight: 0}}, - wantRaw: []string{`"hook_weight": 0`}, - }, - { - name: "fresh install without preDeploy omits note", - plan: &Plan{ - Header: Header{FreshInstall: true}, - Tasks: []PlannedTask{{Name: "backfill", On: TaskOnManual, Manual: true}}, - }, - want: []JSONTask{{Name: "backfill", On: TaskOnManual, Manual: true}}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var buf strings.Builder - require.NoError(t, RenderJSON(&buf, tt.plan)) - var doc JSONDocument - require.NoError(t, json.Unmarshal([]byte(buf.String()), &doc)) - assert.Equal(t, tt.want, doc.Tasks) - assert.Equal(t, tt.wantNote, doc.FirstInstallNote) - for _, raw := range tt.wantRaw { - assert.Contains(t, buf.String(), raw) - } - }) - } -} diff --git a/internal/plan/format_text.go b/internal/plan/format_text.go deleted file mode 100644 index 09f3ade..0000000 --- a/internal/plan/format_text.go +++ /dev/null @@ -1,557 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "fmt" - "io" - "regexp" - "slices" - "strconv" - "strings" - - "nabat.dev/theme" -) - -// Mode selects how [RenderText] displays field paths and values. -type Mode int - -const ( - // ModeCompact maps common Kubernetes paths to Deployah's own spec - // vocabulary (e.g. "spec.template.spec.containers.web.image" becomes - // "image"), falling back to the raw dyff path for anything unmapped. - // This is the default. - ModeCompact Mode = iota - // ModeRaw always shows the raw dyff dot-style path, e.g. - // "spec.template.spec.containers.web.image", bypassing ModeCompact's - // vocabulary mapping. This is `deployah plan --raw`. - ModeRaw - // ModeYAML shows every changed field as a YAML block (path on its own - // line, old/new values indented underneath) instead of a single - // flattened "path: old -> new" line, for both scalar and nested - // map/list values. This is `deployah plan --yaml`. - ModeYAML -) - -// TextOptions controls how [RenderText] formats a [Plan]. -type TextOptions struct { - Mode Mode - // ShowSecrets reveals the real value of fields [ApplyMasking] flagged. - // The caller must refuse this on a non-interactive terminal and must - // never set it together with JSON output; RenderText itself applies it - // unconditionally once given. - ShowSecrets bool - // Theme colors the +/~/- resource lines, header labels, and warning/ - // note text. The zero value renders every style call as the terminal - // default, so callers that don't set this keep plain, uncolored output. - Theme theme.ResolvedTheme -} - -// actionSymbol is the leading per-resource marker, e.g. "~ Deployment/web", -// "+ ConfigMap/web-config", "- Service/legacy-sidecar". -func actionSymbol(a Action) string { - switch a { - case ActionAdd: - return "+" - case ActionDestroy: - return "-" - default: - return "~" - } -} - -// actionToken maps a resource [Action] to the semantic status token whose -// color represents it, matching the convention most diff-style CLIs use: -// green for additions, yellow for in-place changes, red for removals. -func actionToken(a Action) theme.Token { - switch a { - case ActionAdd: - return theme.StatusSuccess - case ActionDestroy: - return theme.StatusError - default: - return theme.StatusWarning - } -} - -// fieldToken is [actionToken]'s field-level counterpart: it colors an -// individual field line by its own [FieldChangeKind], independent of the -// resource's overall Action (e.g. a field added inside an otherwise -// "changed" resource still renders green, not yellow). -func fieldToken(k FieldChangeKind) theme.Token { - switch k { - case FieldAdded: - return theme.StatusSuccess - case FieldRemoved: - return theme.StatusError - default: - return theme.StatusWarning - } -} - -// RenderText writes a human-readable rendering of p to w: a header block -// describing the target release, one line per changed resource with its -// field-level changes indented underneath, and a trailing summary line. -// -// RenderText calls [ApplyMasking] itself (safe to repeat) so a caller can -// never forget it and leak a secret; opts.ShowSecrets is the only way to -// see a masked value in text output. -func RenderText(w io.Writer, p *Plan, opts TextOptions) error { - if p == nil { - return fmt.Errorf("plan is nil") - } - ApplyMasking(p) - - if err := writeHeader(w, p.Header, opts); err != nil { - return err - } - - if err := writeTasks(w, p, opts); err != nil { - return err - } - - hasResourceChanges := len(p.Changes) > 0 - if hasResourceChanges { - if _, err := fmt.Fprintln(w); err != nil { - return err - } - for _, c := range p.Changes { - if err := writeChange(w, c, opts); err != nil { - return err - } - } - } else if !p.chartCRDsPending() { - if _, err := fmt.Fprintln(w, opts.Theme.Style(theme.StatusSuccess).Render("No changes.")); err != nil { - return err - } - } - - if err := writeHookNote(w, p, opts); err != nil { - return err - } - if err := writeChartCRDs(w, p, opts); err != nil { - return err - } - - if hasResourceChanges { - if _, err := fmt.Fprintf(w, "\nPlan: %s.\n", p.Summary.String()); err != nil { - return err - } - } - - return writeDrift(w, p, opts) -} - -// writeDrift renders the drift section when p.DriftChecked is true (i.e. -// `--drift` was requested and ran), under a "Drift (cluster changed outside -// deployah):" heading with a trailing note. It is a no-op when DriftChecked -// is false, so a plan rendered without --drift never grows this section. -func writeDrift(w io.Writer, p *Plan, opts TextOptions) error { - // FreshInstall also short-circuits here: checkDrift never sets - // DriftChecked on a fresh install (no live release to compare against), - // so this is a defensive second guard for callers that bypass it. Why - // --drift had no effect is commentary for stderr, not diff content here. - if !p.DriftChecked || p.Header.FreshInstall { - return nil - } - - heading := opts.Theme.Style(theme.TextTitle).Render("Drift (cluster changed outside deployah):") - if _, err := fmt.Fprintln(w, "\n"+heading); err != nil { - return err - } - - for _, c := range p.Drift { - if err := writeDriftChange(w, c, opts); err != nil { - return err - } - } - if len(p.Drift) == 0 { - if _, err := fmt.Fprintln(w, opts.Theme.Style(theme.StatusSuccess).Render("No drift detected.")); err != nil { - return err - } - } - - if len(p.DriftIncomplete) > 0 { - warning := opts.Theme.Style(theme.StatusWarning).Render("Warning: drift is incomplete; could not check:") - if _, err := fmt.Fprintln(w, "\n"+warning); err != nil { - return err - } - for _, reason := range p.DriftIncomplete { - if _, err := fmt.Fprintf(w, " - %s\n", reason); err != nil { - return err - } - } - } - - note := opts.Theme.Style(theme.TextMuted).Render("Note: deploy does not revert drift. Drifted fields keep their live values\nunless the spec changes them.") - _, err := fmt.Fprintln(w, "\n"+note) - return err -} - -// writeDriftChange is [writeChange]'s drift counterpart: it uses "expected -// X, live Y" wording instead of "X -> Y" so a drift line is never confused -// with an ordinary spec-edit change. -func writeDriftChange(w io.Writer, c Change, opts TextOptions) error { - line := fmt.Sprintf("%s %s/%s", actionSymbol(c.Action), c.Kind, c.Name) - if _, err := fmt.Fprintln(w, opts.Theme.Style(actionToken(c.Action)).Render(line)); err != nil { - return err - } - if opts.Mode == ModeYAML { - return writeYAMLTree(w, c.Fields, opts, yamlLeafStyleDrift) - } - for _, f := range c.Fields { - if err := writeDriftField(w, f, opts); err != nil { - return err - } - } - return nil -} - -// writeDriftField renders one drift field. Old is the predicted (expected) -// value and New is the resource's live value; see [driftOnlyChange] in -// deployah.dev/deployah/internal/drift. Every drift line styles as -// [theme.StatusWarning] regardless of FieldChangeKind: unlike a spec-edit -// change, any drift is the same kind of surprise, so it skips the 3-way -// add/change/remove color split [writeField] uses. -func writeDriftField(w io.Writer, f FieldDiff, opts TextOptions) error { - path := f.Path - if opts.Mode != ModeRaw { - if mapped, ok := mapCompactPath(f.Path); ok { - path = mapped - } - } - - if f.Masked && !opts.ShowSecrets { - return writeMaskedField(w, path, f.ChangeKind, opts) - } - - style := opts.Theme.Style(theme.StatusWarning) - switch f.ChangeKind { - case FieldAdded: - _, err := fmt.Fprintln(w, style.Render(fmt.Sprintf(" %s: (only on cluster) %s", path, f.New))) - return err - case FieldRemoved: - _, err := fmt.Fprintln(w, style.Render(fmt.Sprintf(" %s: (missing on cluster, expected %s)", path, f.Old))) - return err - default: - _, err := fmt.Fprintln(w, style.Render(fmt.Sprintf(" %s: expected %s, live %s", path, f.Old, f.New))) - return err - } -} - -func writeHeader(w io.Writer, h Header, opts TextOptions) error { - type line struct { - label, value string - } - var lines []line - if h.Project != "" { - lines = append(lines, line{"Project", h.Project}) - } - if h.Environment != "" { - lines = append(lines, line{"Environment", h.Environment}) - } - if h.Release != "" { - release := h.Release - switch { - case h.FreshInstall: - release += " (fresh install)" - case h.Revision > 0: - release += fmt.Sprintf(" (revision %d)", h.Revision) - } - lines = append(lines, line{"Release", release}) - } - if h.Namespace != "" { - lines = append(lines, line{"Namespace", h.Namespace}) - } - if h.Context != "" { - lines = append(lines, line{"Context", h.Context}) - } - - labelStyle := opts.Theme.Style(theme.AccentPrimary) - for _, l := range lines { - // Pad the plain label before styling it: ANSI escape codes must not - // count toward the %-12s width, or the values stop lining up. - padded := fmt.Sprintf("%-12s", l.label+":") - if _, err := fmt.Fprintf(w, "%s %s\n", labelStyle.Render(padded), l.value); err != nil { - return err - } - } - - if h.Warning != "" { - warning := opts.Theme.Style(theme.StatusWarning).Render("Warning: " + h.Warning) - if _, err := fmt.Fprintf(w, "\n%s\n", warning); err != nil { - return err - } - } - - return nil -} - -func writeChange(w io.Writer, c Change, opts TextOptions) error { - line := fmt.Sprintf("%s %s/%s", actionSymbol(c.Action), c.Kind, c.Name) - if _, err := fmt.Fprintln(w, opts.Theme.Style(actionToken(c.Action)).Render(line)); err != nil { - return err - } - if opts.Mode == ModeYAML { - return writeYAMLTree(w, c.Fields, opts, yamlLeafStyleChange) - } - for _, f := range c.Fields { - if err := writeField(w, f, opts); err != nil { - return err - } - } - return nil -} - -func writeField(w io.Writer, f FieldDiff, opts TextOptions) error { - path := f.Path - if opts.Mode != ModeRaw { - if mapped, ok := mapCompactPath(f.Path); ok { - path = mapped - } - } - - if f.Masked && !opts.ShowSecrets { - return writeMaskedField(w, path, f.ChangeKind, opts) - } - - style := opts.Theme.Style(fieldToken(f.ChangeKind)) - switch f.ChangeKind { - case FieldAdded: - _, err := fmt.Fprintln(w, style.Render(fmt.Sprintf(" %s: (added) %s", path, f.New))) - return err - case FieldRemoved: - _, err := fmt.Fprintln(w, style.Render(fmt.Sprintf(" %s: (removed) %s", path, f.Old))) - return err - default: - _, err := fmt.Fprintln(w, style.Render(fmt.Sprintf(" %s: %s -> %s", path, f.Old, f.New))) - return err - } -} - -// writeMaskedField prints a masked field's change kind without exposing -// its value, e.g. "(masked) changed" / "(masked) added" / "(masked) -// removed". Styled muted rather than by change kind: the value is hidden, -// so there's nothing to draw the eye to the way a real added/changed/ -// removed value would. -func writeMaskedField(w io.Writer, path string, kind FieldChangeKind, opts TextOptions) error { - line := fmt.Sprintf(" %s: (masked) %s", path, kind) - _, err := fmt.Fprintln(w, opts.Theme.Style(theme.TextMuted).Render(line)) - return err -} - -// indentBlock prefixes the first line of value with prefix and every -// subsequent line with matching whitespace, so multi-line YAML values stay -// legible under a field path. -func indentBlock(value, prefix string) string { - pad := strings.Repeat(" ", len(prefix)) - lines := strings.Split(value, "\n") - for i, l := range lines { - if i == 0 { - lines[i] = prefix + l - } else { - lines[i] = pad + l - } - } - return strings.TrimRight(strings.Join(lines, "\n"), " ") -} - -func writeHookNote(w io.Writer, p *Plan, opts TextOptions) error { - if !p.HooksChanged { - return nil - } - note := opts.Theme.Style(theme.TextMuted).Render("Note: Helm hooks changed for this release (not shown above).") - _, err := fmt.Fprintln(w, "\n"+note) - return err -} - -func writeChartCRDs(w io.Writer, p *Plan, opts TextOptions) error { - if len(p.ChartCRDs) == 0 { - return nil - } - if _, err := fmt.Fprintln(w); err != nil { - return err - } - for i, crd := range p.ChartCRDs { - if i > 0 { - if _, err := fmt.Fprintln(w); err != nil { - return err - } - } - if err := writeChartCRD(w, crd, opts); err != nil { - return err - } - } - return nil -} - -func writeChartCRD(w io.Writer, crd ChartCRD, opts TextOptions) error { - title := fmt.Sprintf("%s/%s", crd.Kind, crd.Name) - note := chartCRDNote(crd.Lifecycle) - if crd.WillProcess { - line := opts.Theme.Style(theme.StatusSuccess).Render("+ " + title) - if _, err := fmt.Fprintln(w, line); err != nil { - return err - } - muted := opts.Theme.Style(theme.TextMuted).Render(" " + note) - if _, err := fmt.Fprintln(w, muted); err != nil { - return err - } - if crd.Source != "" { - src := opts.Theme.Style(theme.TextMuted).Render(" source: " + crd.Source) - if _, err := fmt.Fprintln(w, src); err != nil { - return err - } - } - if crd.YAML == "" { - return nil - } - _, err := fmt.Fprintln(w, indentYAMLBlock(crd.YAML)) - return err - } - line := opts.Theme.Style(theme.TextMuted).Render(title) - if _, err := fmt.Fprintln(w, line); err != nil { - return err - } - muted := opts.Theme.Style(theme.TextMuted).Render(" " + note) - if _, err := fmt.Fprintln(w, muted); err != nil { - return err - } - if crd.Source == "" { - return nil - } - src := opts.Theme.Style(theme.TextMuted).Render(" source: " + crd.Source) - _, err := fmt.Fprintln(w, src) - return err -} - -func chartCRDNote(lifecycle ChartCRDLifecycle) string { - switch lifecycle { - case ChartCRDProcess: - return "Helm install will process this chart CRD" - case ChartCRDSkip: - return "present in chart; install-time CRD processing disabled" - case ChartCRDUpgrade: - return "Helm upgrade will not process this chart CRD" - default: - return "chart CRD lifecycle is not known" - } -} - -func indentYAMLBlock(raw string) string { - raw = strings.TrimRight(raw, "\n") - if raw == "" { - return "" - } - lines := strings.Split(raw, "\n") - for i, line := range lines { - lines[i] = " " + line - } - return strings.Join(lines, "\n") -} - -func writeTasks(w io.Writer, p *Plan, opts TextOptions) error { - if len(p.Tasks) == 0 { - return nil - } - - heading := opts.Theme.Style(theme.TextTitle).Render("Tasks:") - if _, err := fmt.Fprintln(w, "\n"+heading); err != nil { - return err - } - - groups := []struct { - title string - on string - }{ - {TaskOnPreDeploy, TaskOnPreDeploy}, - {TaskOnPostDeploy, TaskOnPostDeploy}, - {"schedule (CronJob)", TaskOnSchedule}, - {"manual (CLI only)", TaskOnManual}, - } - for _, g := range groups { - var items []PlannedTask - for _, task := range p.Tasks { - if task.On == g.on { - items = append(items, task) - } - } - if g.on == TaskOnPreDeploy || g.on == TaskOnPostDeploy { - slices.SortFunc(items, func(a, b PlannedTask) int { - if a.HookWeight != b.HookWeight { - return a.HookWeight - b.HookWeight - } - return strings.Compare(a.Name, b.Name) - }) - } - if len(items) == 0 { - continue - } - if _, err := fmt.Fprintf(w, " %s\n", g.title); err != nil { - return err - } - for _, task := range items { - line := " " + task.Name - if task.Timeout != "" { - line += " (timeout " + task.Timeout + ")" - } - if task.On == TaskOnPreDeploy || task.On == TaskOnPostDeploy { - line += fmt.Sprintf(" weight %d", task.HookWeight) - } - if _, err := fmt.Fprintln(w, line); err != nil { - return err - } - } - } - - if note := p.FirstInstallTaskNote(); note != "" { - styled := opts.Theme.Style(theme.TextMuted).Render("Note: " + note) - if _, err := fmt.Fprintln(w, styled); err != nil { - return err - } - } - return nil -} - -// String renders the summary trailer, e.g. -// "1 to add, 1 to change, 1 to destroy". -func (s Summary) String() string { - return fmt.Sprintf("%s to add, %s to change, %s to destroy", - strconv.Itoa(s.Add), strconv.Itoa(s.Change), strconv.Itoa(s.Destroy)) -} - -// compactPathMappings maps common Kubernetes field paths, in dyff's -// dot-style notation, to Deployah's own spec vocabulary. -var compactPathMappings = []struct { - pattern *regexp.Regexp - display string // may reference regexp capture groups, e.g. "$1" -}{ - {regexp.MustCompile(`^spec\.replicas$`), "replicas"}, - {regexp.MustCompile(`^spec\.template\.spec\.containers\.[^.]+\.image$`), "image"}, - {regexp.MustCompile(`^spec\.template\.spec\.containers\.[^.]+\.ports\.[^.]+\.containerPort$`), "port"}, - {regexp.MustCompile(`^spec\.template\.spec\.containers\.[^.]+\.env\.([^.]+)\.value$`), "env.$1"}, -} - -// mapCompactPath maps a raw dyff path to Deployah's compact vocabulary. It -// returns ok=false for any path with no mapping, so the caller can fall -// back to the raw path unchanged. -func mapCompactPath(path string) (display string, ok bool) { - for _, m := range compactPathMappings { - if m.pattern.MatchString(path) { - return m.pattern.ReplaceAllString(path, m.display), true - } - } - return "", false -} diff --git a/internal/plan/format_text_test.go b/internal/plan/format_text_test.go deleted file mode 100644 index 23261b3..0000000 --- a/internal/plan/format_text_test.go +++ /dev/null @@ -1,543 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "deployah.dev/deployah/internal/spec" -) - -// TestRenderText_HeaderAndMixedChanges covers the named case. -func TestRenderText_HeaderAndMixedChanges(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1+"---\n"+legacySidecar, deploymentV2+"---\n"+configMap) - require.NoError(t, err) - - p.Header = Header{ - Project: "web", - Environment: "production", - Release: "web-production", - Namespace: "default", - Context: "prod-eks-us-east-1", - Revision: 7, - } - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - - got := buf.String() - assert.Contains(t, got, "Project: web\n") - assert.Contains(t, got, "Environment: production\n") - assert.Contains(t, got, "Release: web-production (revision 7)\n") - assert.Contains(t, got, "Namespace: default\n") - assert.Contains(t, got, "Context: prod-eks-us-east-1\n") - assert.Contains(t, got, "~ Deployment/web\n") - assert.Contains(t, got, " image: myapp:v1.2 -> myapp:v1.3\n") - assert.Contains(t, got, "+ ConfigMap/web-config\n") - assert.Contains(t, got, "- Service/legacy-sidecar\n") - assert.Contains(t, got, "Plan: 1 to add, 1 to change, 1 to destroy.\n") -} - -// TestRenderText_FreshInstall covers the named case. -func TestRenderText_FreshInstall(t *testing.T) { - t.Parallel() - p, err := ComputeDiff("", deploymentV1) - require.NoError(t, err) - p.Header = Header{Release: "web-production", FreshInstall: true} - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - - assert.Contains(t, buf.String(), "Release: web-production (fresh install)\n") -} - -// TestRenderText_NoChanges covers the named case. -func TestRenderText_NoChanges(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV1) - require.NoError(t, err) - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - - assert.Contains(t, buf.String(), "No changes.") -} - -// TestRenderText_Warning covers the named case. -func TestRenderText_Warning(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV1) - require.NoError(t, err) - p.Header.Warning = "latest revision 8 is failed; comparing against revision 7 instead" - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - - assert.Contains(t, buf.String(), "Warning: latest revision 8 is failed") -} - -// TestRenderText_RawModeShowsUnmappedPath covers the named case. -func TestRenderText_RawModeShowsUnmappedPath(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV2) - require.NoError(t, err) - - var compact, raw strings.Builder - require.NoError(t, RenderText(&compact, p, TextOptions{Mode: ModeCompact})) - require.NoError(t, RenderText(&raw, p, TextOptions{Mode: ModeRaw})) - - assert.Contains(t, compact.String(), " image: myapp:v1.2 -> myapp:v1.3\n") - assert.Contains(t, raw.String(), " spec.template.spec.containers.web.image: myapp:v1.2 -> myapp:v1.3\n") - assert.NotContains(t, raw.String(), " image:") -} - -// TestRenderText_YAMLModeReconstructsManifestShape verifies ModeYAML -// rebuilds the real nested manifest structure (map keys nest, a named list -// entry like a container renders as "- name: ") instead of a single -// flattened dot-path line, even for a plain scalar leaf value. -func TestRenderText_YAMLModeReconstructsManifestShape(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV2) - require.NoError(t, err) - - var compact, yaml strings.Builder - require.NoError(t, RenderText(&compact, p, TextOptions{Mode: ModeCompact})) - require.NoError(t, RenderText(&yaml, p, TextOptions{Mode: ModeYAML})) - - assert.Contains(t, compact.String(), " image: myapp:v1.2 -> myapp:v1.3\n", - "compact mode keeps the single-line form for a scalar field") - - got := yaml.String() - assert.Contains(t, got, " spec:\n") - assert.Contains(t, got, " template:\n") - assert.Contains(t, got, " spec:\n") - assert.Contains(t, got, " containers:\n") - assert.Contains(t, got, " - name: web\n", - "a name-keyed list entry (the web container) must render as a real YAML list item") - assert.Contains(t, got, " image: myapp:v1.2 -> myapp:v1.3\n", - "the leaf value nests under the reconstructed path instead of repeating the dot path") - assert.NotContains(t, got, "\n image: myapp:v1.2 -> myapp:v1.3\n", - "ModeYAML must not fall back to the single-line compact form (anchored at start of line, unlike the deeper-indented nested leaf)") - assert.NotContains(t, got, "spec.template.spec.containers.web.image", - "ModeYAML must not print the flattened dot path at all") -} - -// deploymentWithSidecarV1/V2 add a whole new named container (a sidecar), -// so dyff reports it as one ADDED diff at the list-item level (New holding -// the entire container as a YAML mapping), rather than a per-field diff -- -// the case ModeYAML's writeYAMLValueBlock exists for. -const deploymentWithSidecarV1 = ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: web - namespace: default -spec: - template: - spec: - containers: - - name: web - image: myapp:v1.2 -` - -const deploymentWithSidecarV2 = ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: web - namespace: default -spec: - template: - spec: - containers: - - name: web - image: myapp:v1.2 - - name: sidecar - image: sidecar:v1.0 -` - -// TestRenderText_YAMLModeWholeListItemAdded verifies a whole new named -// list entry (an entire container added, not a per-field change) renders -// as its own "- name: " list item with its full content dumped -// underneath, instead of one flattened "spec.template...sidecar: (added) -// " line. -func TestRenderText_YAMLModeWholeListItemAdded(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentWithSidecarV1, deploymentWithSidecarV2) - require.NoError(t, err) - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{Mode: ModeYAML})) - - got := buf.String() - assert.Contains(t, got, " - name: sidecar\n", - "the whole new container must render as its own named list item") - assert.Contains(t, got, "name: sidecar", - "the added container's own content (dumped as a YAML block) must appear nested under the list item") - assert.Contains(t, got, "image: sidecar:v1.0") -} - -// TestRenderText_YAMLModeDriftUsesDriftWording verifies the drift section -// under ModeYAML reconstructs the same nested manifest shape as the -// ordinary diff, but with drift's "expected X, live Y" leaf wording -// instead of "X -> Y". -func TestRenderText_YAMLModeDriftUsesDriftWording(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV1) - require.NoError(t, err) - p.DriftChecked = true - drift, err := ComputeDiff(deploymentV1, deploymentV2) - require.NoError(t, err) - p.Drift = drift.Changes - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{Mode: ModeYAML})) - - got := buf.String() - assert.Contains(t, got, "Drift (cluster changed outside deployah):") - assert.Contains(t, got, " - name: web\n") - assert.Contains(t, got, " image: expected myapp:v1.2, live myapp:v1.3\n", - "drift leaves must use 'expected X, live Y' wording even when nested") -} - -// TestRenderText_MaskedSecretHidesValueByDefault covers the named case. -func TestRenderText_MaskedSecretHidesValueByDefault(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(secretV1, secretV2) - require.NoError(t, err) - ApplyMasking(p) - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - - got := buf.String() - assert.Contains(t, got, "(masked) changed") - assert.NotContains(t, got, "old-password") - assert.NotContains(t, got, "new-password") -} - -// TestRenderText_ShowSecretsRevealsValue covers the named case. -func TestRenderText_ShowSecretsRevealsValue(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(secretV1, secretV2) - require.NoError(t, err) - ApplyMasking(p) - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{ShowSecrets: true})) - - assert.Contains(t, buf.String(), "old-password -> new-password") -} - -// TestRenderText_NoDriftSectionWhenNotChecked verifies a plan rendered -// without --drift never grows the drift section, even if Drift happened to -// be empty (the common no-drift-requested case). -func TestRenderText_NoDriftSectionWhenNotChecked(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV1) - require.NoError(t, err) - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - - assert.NotContains(t, buf.String(), "Drift") -} - -// TestRenderText_DriftSection_ShowsExpectedVsLive verifies drift fields use -// "expected X, live Y" wording, distinct from the "X -> Y" wording ordinary -// changes use. -func TestRenderText_DriftSection_ShowsExpectedVsLive(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV1) - require.NoError(t, err) - p.DriftChecked = true - p.Drift = []Change{ - { - Action: ActionChange, - Kind: "Deployment", - Name: "web", - Fields: []FieldDiff{ - {Path: "spec.replicas", ChangeKind: FieldChanged, Old: "2", New: "5"}, - }, - }, - } - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - - got := buf.String() - assert.Contains(t, got, "Drift (cluster changed outside deployah):") - assert.Contains(t, got, "~ Deployment/web") - assert.Contains(t, got, "replicas: expected 2, live 5") - assert.Contains(t, got, "Note: deploy does not revert drift.") -} - -// TestRenderText_DriftSection_NoDriftFound verifies the checked-but-clean -// case is distinguishable from "not requested": it still prints the -// section heading with an explicit "no drift" line. -func TestRenderText_DriftSection_NoDriftFound(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV1) - require.NoError(t, err) - p.DriftChecked = true - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - - got := buf.String() - assert.Contains(t, got, "Drift (cluster changed outside deployah):") - assert.Contains(t, got, "No drift detected.") -} - -// TestRenderText_DriftSection_Incomplete verifies resources drift could not -// be checked for are surfaced as a warning rather than silently dropped. -func TestRenderText_DriftSection_Incomplete(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV1) - require.NoError(t, err) - p.DriftChecked = true - p.DriftIncomplete = []string{"Secret/default/web-secret"} - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - - got := buf.String() - assert.Contains(t, got, "drift is incomplete") - assert.Contains(t, got, "Secret/default/web-secret") -} - -// TestRenderText_DriftSection_FreshInstallIsNoOp verifies --drift on a fresh -// install adds no "Drift (...)" section to stdout, even if DriftChecked ends -// up true alongside FreshInstall (the explanation lives in stderr instead). -func TestRenderText_DriftSection_FreshInstallIsNoOp(t *testing.T) { - t.Parallel() - p, err := ComputeDiff("", deploymentV1) - require.NoError(t, err) - p.Header.FreshInstall = true - p.DriftChecked = true - - var buf strings.Builder - require.NoError(t, RenderText(&buf, p, TextOptions{})) - - got := buf.String() - assert.NotContains(t, got, "Drift (cluster changed outside deployah):") - assert.NotContains(t, got, "no-op on a fresh install") - assert.NotContains(t, got, "No drift detected.") -} - -func TestRenderText_TasksSection(t *testing.T) { - t.Parallel() - - grouped := []PlannedTask{ - {Name: "migrate", On: TaskOnPreDeploy, Timeout: "5m", HookWeight: 0}, - {Name: "seed", On: TaskOnPreDeploy, Timeout: "5m", HookWeight: 1}, - {Name: "smoke", On: TaskOnPostDeploy, Timeout: "5m", HookWeight: 0}, - {Name: "cleanup", On: TaskOnSchedule}, - {Name: "backfill", On: TaskOnManual, Manual: true}, - } - tests := []struct { - name string - plan *Plan - contains []string - omits []string - }{ - { - name: "groups by phase and notes first install", - plan: &Plan{ - Header: Header{ - Project: "shop", - Environment: "dev", - Release: "shop-dev", - FreshInstall: true, - }, - Tasks: grouped, - }, - contains: []string{ - "Tasks:", - "preDeploy", - "migrate (timeout 5m) weight 0", - "seed (timeout 5m) weight 1", - "postDeploy", - "schedule (CronJob)", - "cleanup", - "manual (CLI only)", - "backfill", - "anything it talks to must already be reachable", - }, - }, - { - name: "upgrade omits first install note", - plan: &Plan{ - Header: Header{FreshInstall: false}, - Tasks: grouped[:1], - }, - contains: []string{"Tasks:", "migrate (timeout 5m) weight 0"}, - omits: []string{"anything it talks to must already be reachable"}, - }, - { - name: "postDeploy only skips empty groups", - plan: &Plan{ - Tasks: []PlannedTask{{Name: "smoke", On: TaskOnPostDeploy, Timeout: "5m"}}, - }, - contains: []string{"Tasks:", "postDeploy", "smoke (timeout 5m) weight 0"}, - omits: []string{"preDeploy", "manual", "schedule"}, - }, - { - name: "task without timeout", - plan: &Plan{ - Tasks: []PlannedTask{{Name: "migrate", On: TaskOnPreDeploy}}, - }, - contains: []string{"migrate weight 0"}, - omits: []string{"timeout"}, - }, - { - name: "only manual group", - plan: &Plan{ - Tasks: []PlannedTask{{Name: "backfill", On: TaskOnManual, Manual: true}}, - }, - contains: []string{"Tasks:", "manual (CLI only)", "backfill"}, - omits: []string{"preDeploy", "weight"}, - }, - { - name: "no tasks omits section", - plan: &Plan{Header: Header{FreshInstall: true}}, - omits: []string{"Tasks:"}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var buf strings.Builder - require.NoError(t, RenderText(&buf, tt.plan, TextOptions{})) - got := buf.String() - for _, s := range tt.contains { - assert.Contains(t, got, s) - } - for _, s := range tt.omits { - assert.NotContains(t, got, s) - } - }) - } -} - -func TestPlan_FirstInstallTaskNote(t *testing.T) { - t.Parallel() - - const note = "preDeploy runs before other resources on a first install; anything it talks to must already be reachable." - tests := []struct { - name string - plan *Plan - want string - }{ - {name: "nil plan", plan: nil, want: ""}, - {name: "not fresh install", plan: &Plan{Tasks: []PlannedTask{{On: TaskOnPreDeploy}}}, want: ""}, - {name: "fresh install without preDeploy", plan: &Plan{Header: Header{FreshInstall: true}, Tasks: []PlannedTask{{On: TaskOnManual}}}, want: ""}, - {name: "fresh install with preDeploy", plan: &Plan{Header: Header{FreshInstall: true}, Tasks: []PlannedTask{{On: TaskOnPreDeploy}}}, want: note}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tt.want, tt.plan.FirstInstallTaskNote()) - }) - } -} - -func TestTasksFromSpec(t *testing.T) { - t.Parallel() - - m := &spec.Spec{ - Project: "shop", - Components: map[string]spec.Component{ - "api": {Image: "nginx:latest", Environments: []string{"prod"}}, - }, - Tasks: map[string]spec.Task{ - "migrate": {From: "api", On: spec.TaskOnPreDeploy, Command: []string{"true"}}, - "smoke": {From: "api", On: spec.TaskOnPostDeploy, Environments: []string{"dev", "prod"}, Command: []string{"true"}}, - "nightly": {From: "api", On: spec.TaskOnManual, Environments: []string{"prod"}, Command: []string{"true"}}, - }, - } - tests := []struct { - name string - environment string - want []PlannedTask - }{ - { - name: "dev skips inherited prod-only parent", - environment: "dev", - want: []PlannedTask{ - {Name: "smoke", On: TaskOnPostDeploy}, - }, - }, - { - name: "prod includes inherited and explicit", - environment: "prod", - want: []PlannedTask{ - {Name: "migrate", On: TaskOnPreDeploy}, - {Name: "nightly", On: TaskOnManual, Manual: true}, - {Name: "smoke", On: TaskOnPostDeploy}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got, err := TasksFromSpec(m, tt.environment, nil) - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestTasksFromSpec_Cycle(t *testing.T) { - t.Parallel() - m := &spec.Spec{ - Project: "shop", - Tasks: map[string]spec.Task{ - "a": {On: spec.TaskOnPreDeploy, After: []string{"b"}, Command: []string{"true"}}, - "b": {On: spec.TaskOnPreDeploy, After: []string{"a"}, Command: []string{"true"}}, - }, - } - _, err := TasksFromSpec(m, "dev", nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "cycle") -} - -func TestMapCompactPath(t *testing.T) { - t.Parallel() - tests := []struct { - path string - display string - ok bool - }{ - {"spec.replicas", "replicas", true}, - {"spec.template.spec.containers.web.image", "image", true}, - {"spec.template.spec.containers.web.ports.http.containerPort", "port", true}, - {"spec.template.spec.containers.web.env.NODE_ENV.value", "env.NODE_ENV", true}, - {"metadata.labels.foo", "", false}, - } - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - t.Parallel() - display, ok := mapCompactPath(tt.path) - assert.Equal(t, tt.ok, ok) - assert.Equal(t, tt.display, display) - }) - } -} diff --git a/internal/plan/history.go b/internal/plan/history.go index 5b43946..3367be5 100644 --- a/internal/plan/history.go +++ b/internal/plan/history.go @@ -38,10 +38,10 @@ type historyClient interface { // LastSuccessfulRelease walks a release's history, newest revision first, // and returns the newest revision whose status is "deployed" or -// "superseded": the manifest a plan should diff the current render -// against. warning is set when the newest revision itself isn't -// successful, so the caller can surface that alongside the older -// successful revision actually used for the diff. +// "superseded". Deploy hostname and workload guards use it. +// [BuildSemanticPlan] does not. Warning is set when the newest revision +// itself is not successful, so the caller can surface that alongside the +// older successful revision. func LastSuccessfulRelease(ctx context.Context, client historyClient, project, environment string) (release *v1.Release, warning string, err error) { history, err := client.GetReleaseHistory(ctx, project, environment) if err != nil { @@ -60,8 +60,8 @@ func LastSuccessfulRelease(ctx context.Context, client historyClient, project, e }) if len(releases) == 0 { - // No history at all: treat as a fresh install, where every - // resource in the current render is an addition. + // No history at all: there is no successful revision for the + // deploy guards. return nil, "", nil } @@ -79,9 +79,9 @@ func LastSuccessfulRelease(ctx context.Context, client historyClient, project, e } } - // History exists but no revision ever succeeded (e.g. every attempt - // failed). Treat like a fresh install for diffing purposes, but keep - // the warning so the caller knows this is not really a first deploy. + // History exists but no revision ever succeeded (every attempt + // failed). The nil return means the guards have no successful + // release to compare. The warning says this is not a first deploy. if warning == "" { warning = fmt.Sprintf("no successful revision found in history (latest revision %d is %s)", latest.Version, latest.Info.Status) } diff --git a/internal/plan/hooks_test.go b/internal/plan/hooks_test.go deleted file mode 100644 index 0084bc9..0000000 --- a/internal/plan/hooks_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - v1 "helm.sh/helm/v4/pkg/release/v1" -) - -// TestHooksChanged covers the named case. -func TestHooksChanged(t *testing.T) { - t.Parallel() - preInstall := &v1.Hook{Name: "templates/hooks/pre-install.yaml", Manifest: "kind: Job\nspec: v1"} - - tests := []struct { - name string - previous []*v1.Hook - current []*v1.Hook - want bool - }{ - {name: "both empty", previous: nil, current: nil, want: false}, - {name: "identical", previous: []*v1.Hook{preInstall}, current: []*v1.Hook{preInstall}, want: false}, - { - name: "content changed", - previous: []*v1.Hook{preInstall}, - current: []*v1.Hook{{Name: preInstall.Name, Manifest: "kind: Job\nspec: v2"}}, - want: true, - }, - {name: "hook added", previous: nil, current: []*v1.Hook{preInstall}, want: true}, - {name: "hook removed", previous: []*v1.Hook{preInstall}, current: nil, want: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tt.want, HooksChanged(tt.previous, tt.current)) - }) - } -} diff --git a/internal/plan/intent.go b/internal/plan/intent.go deleted file mode 100644 index 73d846e..0000000 --- a/internal/plan/intent.go +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -// DeploymentIntent is the mutation and executability flags a deploy would -// use. Presentation flags such as output format are not part of intent. -// Chart CRD skip lives on deploy.Options, not here: Helm install owns -// that flag, and this struct is not the Helm action. -type DeploymentIntent struct { - // ResizeVolumes enables persistent volume claim expansion. - ResizeVolumes bool - // ForceHostnameChange allows a hostname change that would otherwise be - // blocked. - ForceHostnameChange bool -} - -// DefaultDeploymentIntent returns the deploy defaults: all boolean flags -// unset. -func DefaultDeploymentIntent() DeploymentIntent { - return DeploymentIntent{} -} diff --git a/internal/plan/intent_test.go b/internal/plan/intent_test.go deleted file mode 100644 index 2466e9c..0000000 --- a/internal/plan/intent_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestDefaultDeploymentIntent(t *testing.T) { - t.Parallel() - - got := DefaultDeploymentIntent() - assert.False(t, got.ResizeVolumes) - assert.False(t, got.ForceHostnameChange) -} - -func TestDeploymentIntent_ZeroValueMatchesDefault(t *testing.T) { - t.Parallel() - - var zero DeploymentIntent - assert.Equal(t, DefaultDeploymentIntent(), zero) -} - -// deploymentIntentFields is the allowed field set of [DeploymentIntent]. -// Converting DeploymentIntent to this type fails to compile if a -// presentation field or CRD skip flag is added. -type deploymentIntentFields struct { - ResizeVolumes bool - ForceHostnameChange bool -} - -var _ = deploymentIntentFields(DeploymentIntent{}) - -func TestDeploymentIntent_BoolsDefaultFalse(t *testing.T) { - t.Parallel() - - got := deploymentIntentFields(DefaultDeploymentIntent()) - assert.False(t, got.ResizeVolumes) - assert.False(t, got.ForceHostnameChange) -} diff --git a/internal/plan/mask.go b/internal/plan/mask.go deleted file mode 100644 index 44674f5..0000000 --- a/internal/plan/mask.go +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import "strings" - -// secretMaskedTopLevelFields lists the Kubernetes Secret mapping keys that -// hold the secret's actual payload, as opposed to metadata like Type. Both -// are checked because a Secret can populate either or both, depending on -// how the value was declared in the chart. -var secretMaskedTopLevelFields = []string{"data", "stringData"} - -// ApplyMasking flags every FieldDiff belonging to a Secret resource's data -// or stringData block as Masked. It must run on every [Plan] before -// display, including --output json, since the JSON schema always masks -// secrets: see [FieldDiff]. -// -// Masking is based on resource kind and field location, not on where a -// value came from during templating, so it can't be bypassed by routing a -// secret value through the chart differently. -func ApplyMasking(p *Plan) { - if p == nil { - return - } - maskSecretFields(p.Changes) - maskSecretFields(p.Drift) -} - -// maskSecretFields flags every FieldDiff under a Secret's data or -// stringData block as Masked, across both the ordinary changes and drift. -func maskSecretFields(changes []Change) { - for i := range changes { - c := &changes[i] - if c.Kind != "Secret" { - continue - } - for j := range c.Fields { - if isSecretDataPath(c.Fields[j].Path) { - c.Fields[j].Masked = true - } - } - } -} - -// isSecretDataPath reports whether a dyff dot-style path (as produced by -// [ComputeDiff]) falls under a Secret's data or stringData block, e.g. -// "data.password" or "stringData.API_KEY". -func isSecretDataPath(path string) bool { - for _, prefix := range secretMaskedTopLevelFields { - if path == prefix || strings.HasPrefix(path, prefix+".") { - return true - } - } - return false -} diff --git a/internal/plan/mask_test.go b/internal/plan/mask_test.go deleted file mode 100644 index 2f72fd0..0000000 --- a/internal/plan/mask_test.go +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const secretV1 = ` -apiVersion: v1 -kind: Secret -metadata: - name: web-secret - namespace: default -type: Opaque -stringData: - password: old-password -data: - token: b2xkLXRva2Vu -` - -const secretV2 = ` -apiVersion: v1 -kind: Secret -metadata: - name: web-secret - namespace: default -type: Opaque -stringData: - password: new-password -data: - token: bmV3LXRva2Vu -` - -// TestApplyMasking_MasksSecretDataAndStringData covers the named case. -func TestApplyMasking_MasksSecretDataAndStringData(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(secretV1, secretV2) - require.NoError(t, err) - require.Len(t, p.Changes, 1) - require.Len(t, p.Changes[0].Fields, 2) - - // Before masking, values are visible (a renderer honoring --show-secrets - // still needs them). - for _, f := range p.Changes[0].Fields { - assert.False(t, f.Masked) - assert.NotEmpty(t, f.Old) - assert.NotEmpty(t, f.New) - } - - ApplyMasking(p) - - for _, f := range p.Changes[0].Fields { - assert.True(t, f.Masked, "path %s should be masked", f.Path) - // Masking flags the field; it does not destroy the values, so a - // text renderer can still honor --show-secrets. - assert.NotEmpty(t, f.Old) - assert.NotEmpty(t, f.New) - } -} - -// TestApplyMasking_NonSecretResourceUntouched covers the named case. -func TestApplyMasking_NonSecretResourceUntouched(t *testing.T) { - t.Parallel() - p, err := ComputeDiff(deploymentV1, deploymentV2) - require.NoError(t, err) - - ApplyMasking(p) - - require.Len(t, p.Changes, 1) - for _, f := range p.Changes[0].Fields { - assert.False(t, f.Masked) - } -} - -// TestIsSecretDataPath covers Secret data/stringData path matching. -func TestIsSecretDataPath(t *testing.T) { - t.Parallel() - tests := []struct { - path string - want bool - }{ - {path: "data", want: true}, - {path: "data.token", want: true}, - {path: "stringData", want: true}, - {path: "stringData.API_KEY", want: true}, - {path: "type", want: false}, - {path: "metadata.name", want: false}, - // must not match a "data"-prefixed key that isn't actually data.* - {path: "databases", want: false}, - } - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tt.want, isSecretDataPath(tt.path)) - }) - } -} diff --git a/internal/plan/normalize.go b/internal/plan/normalize.go deleted file mode 100644 index 123f41f..0000000 --- a/internal/plan/normalize.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "slices" - - yamlv3 "go.yaml.in/yaml/v3" -) - -// noiseAnnotations lists metadata.annotations keys that Helm/kubectl add -// but no Deployah chart template sets. They only appear when diffing -// against a live cluster object (--drift), not two rendered manifests, so -// stripping them keeps normalizeResource correct for both cases. -var noiseAnnotations = []string{ - "meta.helm.sh/release-name", - "meta.helm.sh/release-namespace", - "kubectl.kubernetes.io/last-applied-configuration", -} - -// noiseMetadataFields lists metadata.* keys the API server populates that -// are never meaningful to a spec-driven change: they either change on -// every apply (resourceVersion, generation) or identify the object rather -// than its desired state (uid, creationTimestamp, managedFields). -var noiseMetadataFields = []string{ - "resourceVersion", - "uid", - "generation", - "creationTimestamp", - "managedFields", -} - -// normalizeResource strips noise fields from a parsed Kubernetes resource -// document in place, so [diffResource] never reports a "change" caused by -// server-populated bookkeeping. node must be a document node as produced by -// [parseResources] (Kind == yamlv3.DocumentNode, Content[0] the mapping node). -func normalizeResource(node *yamlv3.Node) { - if node == nil || node.Kind != yamlv3.DocumentNode || len(node.Content) != 1 { - return - } - root := node.Content[0] - if root.Kind != yamlv3.MappingNode { - return - } - - deleteMapKey(root, "status") - - metadata, metaOK := mapValue(root, "metadata") - if !metaOK || metadata.Kind != yamlv3.MappingNode { - return - } - - for _, key := range noiseMetadataFields { - deleteMapKey(metadata, key) - } - - if annotations, annOK := mapValue(metadata, "annotations"); annOK && annotations.Kind == yamlv3.MappingNode { - for _, key := range noiseAnnotations { - deleteMapKey(annotations, key) - } - if len(annotations.Content) == 0 { - deleteMapKey(metadata, "annotations") - } - } -} - -// mapValue returns the value node for key in a YAML mapping node, following -// aliases on both the key and value the same way dyff's own comparator does. -func mapValue(mapping *yamlv3.Node, key string) (*yamlv3.Node, bool) { - for i := 0; i+1 < len(mapping.Content); i += 2 { - k := followAlias(mapping.Content[i]) - if k.Value == key { - return followAlias(mapping.Content[i+1]), true - } - } - return nil, false -} - -// deleteMapKey removes key (and its value) from a YAML mapping node's -// Content in place. It reports whether the key was found. -func deleteMapKey(mapping *yamlv3.Node, key string) bool { - for i := 0; i+1 < len(mapping.Content); i += 2 { - if mapping.Content[i].Value == key { - mapping.Content = slices.Delete(mapping.Content, i, i+2) - return true - } - } - return false -} - -// followAlias resolves a YAML alias node to the node it points to, mirroring -// dyff's own unexported helper of the same name since normalizeResource runs -// before resources reach dyff. -func followAlias(node *yamlv3.Node) *yamlv3.Node { - if node != nil && node.Kind == yamlv3.AliasNode && node.Alias != nil { - return followAlias(node.Alias) - } - return node -} diff --git a/internal/plan/resource.go b/internal/plan/resource.go deleted file mode 100644 index b658b5e..0000000 --- a/internal/plan/resource.go +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "fmt" - - "github.com/gonvenience/ytbx" - - yamlv3 "go.yaml.in/yaml/v3" -) - -// resourceKey identifies a Kubernetes resource across two manifests so -// resources can be matched independently of their position in the rendered -// YAML stream. Namespace is empty for cluster-scoped resources. -type resourceKey struct { - APIVersion string - Kind string - Namespace string - Name string -} - -// resourceDoc pairs a resourceKey with the parsed YAML document node so -// [diffResource] can run dyff on it. -type resourceDoc struct { - key resourceKey - node *yamlv3.Node // Kind == yamlv3.DocumentNode -} - -// resourceIdentity mirrors the fields dyff's own Kubernetes entity detector -// reads (apiVersion, kind, metadata.name, metadata.namespace), decoded as -// Go-typed fields rather than split back out of a slash-joined string -- -// apiVersion values like "apps/v1" already contain a slash. -type resourceIdentity struct { - APIVersion string `yaml:"apiVersion"` - Kind string `yaml:"kind"` - Metadata struct { - Name string `yaml:"name"` - Namespace string `yaml:"namespace"` - } `yaml:"metadata"` -} - -// parseResources splits a multi-document Kubernetes manifest (as rendered by -// Helm, "---"-separated) into one [resourceDoc] per non-empty document, in -// manifest order. Documents missing apiVersion, kind, or metadata.name are -// rejected: a rendered chart is expected to only ever produce well-formed -// Kubernetes objects. -func parseResources(manifest string) ([]resourceDoc, error) { - docs, err := ytbx.LoadYAMLDocuments([]byte(manifest)) - if err != nil { - return nil, fmt.Errorf("parsing manifest YAML: %w", err) - } - - out := make([]resourceDoc, 0, len(docs)) - for _, doc := range docs { - if isEmptyDocument(doc) { - continue - } - if doc.Kind != yamlv3.DocumentNode || len(doc.Content) != 1 { - return nil, fmt.Errorf("unexpected YAML document shape in rendered manifest") - } - - var id resourceIdentity - if decodeErr := doc.Content[0].Decode(&id); decodeErr != nil { - return nil, fmt.Errorf("decoding resource identity: %w", decodeErr) - } - if id.APIVersion == "" || id.Kind == "" || id.Metadata.Name == "" { - return nil, fmt.Errorf("rendered resource missing apiVersion, kind, or metadata.name") - } - - out = append(out, resourceDoc{ - key: resourceKey{ - APIVersion: id.APIVersion, - Kind: id.Kind, - Namespace: id.Metadata.Namespace, - Name: id.Metadata.Name, - }, - node: doc, - }) - } - return out, nil -} - -// ResourceYAML is one Kubernetes resource extracted from a rendered -// manifest, re-encoded as a standalone single-document YAML string. -type ResourceYAML struct { - // Label is "Kind/name" (or "Kind/namespace/name" for namespaced - // resources), the same compact identifier [ComputeDiff] uses to key - // resources internally. - Label string - // YAML is the resource's own manifest, on its own (no "---" separator - // or sibling documents). - YAML string -} - -// SplitResources splits a rendered manifest into one [ResourceYAML] per -// contained Kubernetes resource, using the same parsing [ComputeDiff] uses. -// It backs drift detection (deployah.dev/deployah/internal/drift), which -// runs a server-side apply dry-run against each resource individually. -func SplitResources(manifest string) ([]ResourceYAML, error) { - docs, err := parseResources(manifest) - if err != nil { - return nil, err - } - out := make([]ResourceYAML, 0, len(docs)) - for _, d := range docs { - b, marshalErr := yamlv3.Marshal(d.node.Content[0]) - if marshalErr != nil { - return nil, fmt.Errorf("re-encode resource %s: %w", d.key, marshalErr) - } - out = append(out, ResourceYAML{Label: d.key.String(), YAML: string(b)}) - } - return out, nil -} - -// isEmptyDocument reports whether node is a YAML document containing only a -// null scalar, which happens when a template renders to nothing (e.g. an -// "if" block that evaluates false) between two "---" separators. -func isEmptyDocument(node *yamlv3.Node) bool { - if node.Kind != yamlv3.DocumentNode { - return false - } - if len(node.Content) != 1 { - return false - } - c := node.Content[0] - return c.Kind == yamlv3.ScalarNode && c.Tag == "!!null" -} - -// indexResources builds a lookup map from resourceKey to resourceDoc. It -// assumes keys are unique within docs, which holds for well-formed -// Kubernetes manifests (the API server itself rejects duplicate names). -func indexResources(docs []resourceDoc) map[resourceKey]resourceDoc { - m := make(map[resourceKey]resourceDoc, len(docs)) - for _, d := range docs { - m[d.key] = d - } - return m -} - -// String renders a resourceKey as "Kind/name" (or "Kind/namespace/name" for -// namespaced resources), the compact resource label used throughout plan -// output. -func (k resourceKey) String() string { - if k.Namespace == "" { - return fmt.Sprintf("%s/%s", k.Kind, k.Name) - } - return fmt.Sprintf("%s/%s/%s", k.Kind, k.Namespace, k.Name) -} diff --git a/internal/plan/resource_test.go b/internal/plan/resource_test.go deleted file mode 100644 index cc2c1d8..0000000 --- a/internal/plan/resource_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const splitResourcesManifest = ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: web - namespace: default -spec: - replicas: 2 ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: web-config - namespace: default -data: - key: value -` - -// TestSplitResources_OneEntryPerResource verifies each "---"-separated -// document becomes its own [ResourceYAML], labeled and re-encoded correctly. -func TestSplitResources_OneEntryPerResource(t *testing.T) { - t.Parallel() - resources, err := SplitResources(splitResourcesManifest) - require.NoError(t, err) - require.Len(t, resources, 2) - - assert.Equal(t, "Deployment/default/web", resources[0].Label) - assert.Contains(t, resources[0].YAML, "kind: Deployment") - assert.Contains(t, resources[0].YAML, "replicas: 2") - assert.NotContains(t, resources[0].YAML, "---", "each entry must be a standalone document") - - assert.Equal(t, "ConfigMap/default/web-config", resources[1].Label) - assert.Contains(t, resources[1].YAML, "kind: ConfigMap") -} - -// TestSplitResources_EmptyManifest covers the named case. -func TestSplitResources_EmptyManifest(t *testing.T) { - t.Parallel() - resources, err := SplitResources("") - require.NoError(t, err) - assert.Empty(t, resources) -} - -// TestSplitResources_InvalidManifest covers the named case. -func TestSplitResources_InvalidManifest(t *testing.T) { - t.Parallel() - _, err := SplitResources("kind: Pod\nmetadata: {}\n") - require.Error(t, err) -} diff --git a/internal/plan/semantic_build_extras_test.go b/internal/plan/semantic_build_extras_test.go new file mode 100644 index 0000000..0938199 --- /dev/null +++ b/internal/plan/semantic_build_extras_test.go @@ -0,0 +1,207 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/chart/v2/loader" + "helm.sh/helm/v4/pkg/postrenderer" + + "deployah.dev/deployah/internal/extras" + "deployah.dev/deployah/internal/helm" + "deployah.dev/deployah/internal/plan" + "deployah.dev/deployah/internal/plan/semantic" + "deployah.dev/deployah/internal/render" + "deployah.dev/deployah/internal/spec" + + v1 "helm.sh/helm/v4/pkg/release/v1" +) + +const ( + extrasPlanProject = "plan-extras-fresh-install" + extrasPlanEnv = "dev" + extrasPlanNamespace = "dev" + extrasPlanPolicy = "plan-extras-deny" +) + +// TestBuildSemanticPlan_ExtraManifestIsFreshInstallCreate is the regression +// for a resource loaded from .deployah/manifests/. LoadFromSpec, the extras +// post-renderer, and the Helm client-only render run, then BuildSemanticPlan +// must report that object as a fresh-install create beside the chart resources. +func TestBuildSemanticPlan_ExtraManifestIsFreshInstallCreate(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + specPath := filepath.Join(dir, "deployah.yaml") + writeExtrasPlanFile(t, specPath, extrasPlanSpec) + writeExtrasPlanFile(t, filepath.Join(dir, ".deployah", "manifests", "networkpolicy.yaml"), extrasPlanNetworkPolicy) + + manifest, subst, err := spec.Load(t.Context(), specPath, extrasPlanEnv, nil) + require.NoError(t, err) + envName, _, err := spec.ResolveEnvironment(manifest.Environments, nil, extrasPlanEnv) + require.NoError(t, err) + resolved, _, err := spec.Resolve(manifest, nil, spec.NormalizeEnv(envName), subst) + require.NoError(t, err) + + bundle, err := extras.LoadFromSpec(specPath, manifest, nil, extrasPlanEnv, extrasPlanNamespace, nil) + require.NoError(t, err) + post := bundle.PostRendererFor() + require.NotNil(t, post) + + client := &extrasInstallClient{ + tb: t, + cache: helm.NewChartCache(time.Hour), + namespace: extrasPlanNamespace, + } + p, result, cleanup, err := plan.BuildSemanticPlan( + t.Context(), + client, + newMapper(), + nil, + plan.SemanticBuildInput{ + ClusterContext: "kind-dev", + Resolved: resolved, + PostRenderer: post, + }, + ) + t.Cleanup(cleanup) + require.NoError(t, err) + require.NotNil(t, result) + assert.Contains(t, result.Manifest, "kind: NetworkPolicy") + assert.Contains(t, result.Manifest, "name: "+extrasPlanPolicy) + + generated := helm.GenerateReleaseName(extrasPlanProject, extrasPlanEnv) + "-web" + assert.True(t, p.Header.FreshInstall) + assert.Equal(t, semantic.HelmInstall, p.HelmAction) + assert.Equal(t, extrasPlanProject, p.Header.Project) + assert.Equal(t, extrasPlanEnv, p.Header.Environment) + assert.Equal(t, extrasPlanNamespace, p.Header.Namespace) + assert.Equal(t, helm.GenerateReleaseName(extrasPlanProject, extrasPlanEnv), p.Header.Release) + + got := make([]string, 0, len(p.Changes)) + for _, change := range p.Changes { + assert.Equal(t, semantic.Create, change.Action) + assert.Equal(t, extrasPlanNamespace, change.Resource.Namespace) + assert.Nil(t, change.Before) + require.NotNil(t, change.After) + assert.Empty(t, change.Fields) + got = append(got, change.Resource.APIVersion+" "+change.Resource.Kind+" "+change.Resource.Name) + } + assert.ElementsMatch(t, []string{ + "apps/v1 Deployment " + generated, + "networking.k8s.io/v1 NetworkPolicy " + extrasPlanPolicy, + "v1 Service " + generated, + }, got) +} + +// extrasInstallClient renders a fresh install with Helm's client-only dry run +// and applies the PostRenderer argument to that render. +type extrasInstallClient struct { + tb testing.TB + cache *helm.ChartCache + namespace string +} + +func (c *extrasInstallClient) RenderManifestsWithPrep( + ctx context.Context, + resolved *spec.ResolvedSpec, + postRenderer postrenderer.PostRenderer, + crds []extras.RawFile, +) (*render.RenderResult, helm.ReleasePrep, func(), error) { + noop := func() {} + chartPath, err := helm.PrepareChart(ctx, resolved, c.cache, crds) + if err != nil { + return nil, helm.ReleasePrep{}, noop, fmt.Errorf("prepare chart: %w", err) + } + cleanup := func() { + if removeErr := os.RemoveAll(chartPath); removeErr != nil { + c.tb.Errorf("remove chart dir %s: %v", chartPath, removeErr) + } + } + + chart, err := loader.Load(chartPath) + if err != nil { + return nil, helm.ReleasePrep{}, cleanup, fmt.Errorf("load chart: %w", err) + } + releaseName := helm.GenerateReleaseName(resolved.Spec.Project, resolved.Env.Original) + install := action.NewInstall(action.NewConfiguration()) + install.ReleaseName = releaseName + install.Namespace = c.namespace + install.DryRunStrategy = action.DryRunClient + install.DisableOpenAPIValidation = true + install.PostRenderer = postRenderer + + rel, err := install.RunWithContext(ctx, chart, map[string]any{}) + if err != nil { + return nil, helm.ReleasePrep{}, cleanup, fmt.Errorf("helm install dry run: %w", err) + } + v1rel, ok := rel.(*v1.Release) + if !ok { + return nil, helm.ReleasePrep{}, cleanup, fmt.Errorf("unexpected helm release type %T", rel) + } + result := &render.RenderResult{ + ReleaseName: releaseName, + Namespace: install.Namespace, + Manifest: v1rel.Manifest, + Hooks: v1rel.Hooks, + IsUpgrade: false, + Revision: 1, + ChartPath: chartPath, + } + prep := helm.ReleasePrep{ + Operation: helm.OperationInstall, + NextRevision: 1, + } + return result, prep, cleanup, nil +} + +func writeExtrasPlanFile(t *testing.T, path, body string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750)) + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) +} + +const extrasPlanSpec = `apiVersion: v1-alpha.5 +project: plan-extras-fresh-install +components: + web: + image: nginx:latest + port: 8080 + environments: [dev] + resourcePreset: small +environments: + dev: {} +` + +const extrasPlanNetworkPolicy = `apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: plan-extras-deny +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: web + policyTypes: + - Ingress +` diff --git a/internal/plan/types.go b/internal/plan/types.go deleted file mode 100644 index c67d688..0000000 --- a/internal/plan/types.go +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import "errors" - -// ErrChangesPresent is returned by the `deployah plan` command when -// --detailed-exitcode is set and the computed plan has at least one change. -// internal/cmd/root.go checks for it with [errors.Is] to translate it into -// exit code 2 instead of the generic exit code 1. -var ErrChangesPresent = errors.New("plan has pending changes") - -// Action classifies how a resource changes between the previous and current -// manifest. -type Action string - -const ( - // ActionAdd means the resource exists in the current manifest but not in - // the previous one. - ActionAdd Action = "add" - // ActionChange means the resource exists on both sides with at least one - // field difference. - ActionChange Action = "change" - // ActionDestroy means the resource exists in the previous manifest but - // not in the current one. - ActionDestroy Action = "destroy" -) - -// FieldChangeKind classifies one field-level difference within a resource. -type FieldChangeKind string - -const ( - // FieldAdded means the field is present in the current resource only. - FieldAdded FieldChangeKind = "added" - // FieldChanged means the field's value differs between the two resources. - FieldChanged FieldChangeKind = "changed" - // FieldRemoved means the field is present in the previous resource only. - FieldRemoved FieldChangeKind = "removed" -) - -// FieldDiff is one field-level difference inside a [Change], using dyff's -// dot-style, name-keyed path notation. Old is meaningless when ChangeKind -// is [FieldAdded], New when it's [FieldRemoved]. When Masked is true, Old -// and New still hold the real values (for --show-secrets); the JSON -// renderer must always omit them regardless. -type FieldDiff struct { - Path string - ChangeKind FieldChangeKind - Old string - New string - Masked bool - // Segments is Path broken into its structured parts, so a renderer can - // reconstruct the real nested manifest shape (ModeYAML) instead of - // working from the flattened dot string. Derived from dyff's own - // ytbx.PathElement list, which is the only source that distinguishes a - // plain map key from a named list-entry identifier -- the flattened - // Path string alone cannot tell "containers.web" apart from a map with - // a literal key "web". - Segments []PathSegment -} - -// PathSegment is one element of a [FieldDiff]'s structured path. -type PathSegment struct { - // Name is the map key, or -- when ListKey is set -- the value that - // identifies one entry in a named-entry list (e.g. "web" in a - // container list entry matched by its "name" field). - Name string - // ListKey is the identifying field name for a named-entry list item - // (almost always "name" for Kubernetes; occasionally "key" or another - // field dyff detected as unique). Empty for a plain map key or a - // positional list index. - ListKey string - // Idx is the positional index into a list whose entries dyff could not - // match by identity (e.g. a plain string list like `command`). -1 for - // a map key or a named list-entry segment. - Idx int -} - -// Change describes one Kubernetes resource that differs between the -// previous and current manifest. -type Change struct { - Action Action - Kind string - APIVersion string - Name string - Namespace string - // Fields is empty for [ActionAdd] and [ActionDestroy]; the whole - // resource is the change in those cases. - Fields []FieldDiff -} - -// Summary counts changes by action for the trailer line ("Plan: 1 to add, ..."). -type Summary struct { - Add int - Change int - Destroy int -} - -// Total returns the total number of changed resources. -func (s Summary) Total() int { - return s.Add + s.Change + s.Destroy -} - -// Header carries the identifying and contextual information shown above the -// list of changes: which project, environment, release, and cluster this -// plan describes. -type Header struct { - Project string - Environment string - Release string - Namespace string - Context string - - // Revision is the current (last successful) release revision. It is - // meaningless when FreshInstall is true. - Revision int - // FreshInstall is true when no prior successful release exists, so - // every resource in the current manifest renders as an addition. - FreshInstall bool - // Warning is a non-fatal note about the release history, e.g. that the - // latest revision failed or is pending and the plan compares against an - // older successful revision instead. - Warning string -} - -// Plan is the full result of comparing a previous manifest (the last -// successful release, or none on a fresh install) against a freshly -// rendered current manifest. -type Plan struct { - Header Header - Changes []Change - Summary Summary - // HooksChanged is true when the release's Helm hooks differ between the - // previous and current render but are not otherwise represented in - // Changes (hooks are not regular cluster resources tracked by the diff). - HooksChanged bool - - // DriftChecked is true when `--drift` ran, regardless of outcome, so - // renderers can tell "checked, found nothing" from "not requested" even - // though Drift is empty in both cases. - DriftChecked bool - // Drift lists fields that differ between a server-side apply - // prediction and a resource's live state, but are not already - // explained by Changes. See deployah.dev/deployah/internal/drift. - Drift []Change - // DriftIncomplete lists resource labels drift could not be checked for - // (e.g. missing RBAC), so the plan can say it is incomplete instead of - // silently omitting them. - DriftIncomplete []string - - // Tasks lists spec tasks active in this environment, grouped by the - // renderer into preDeploy, postDeploy, schedule, and manual. - Tasks []PlannedTask - - // ChartCRDs are lifecycle entries for documents under .deployah/crds/. - // They are not predicted Kubernetes resource mutations. - ChartCRDs []ChartCRD -} - -// ChartCRDLifecycle is Helm's install-only handling of one chart CRD -// document in this invocation. -type ChartCRDLifecycle string - -const ( - // ChartCRDProcess means a fresh install will ask Helm to process the - // chart CRD. It does not claim Kubernetes will create versus apply. - ChartCRDProcess ChartCRDLifecycle = "process" - // ChartCRDSkip means the CRD is in the chart but --skip-crds disabled - // install-time processing. - ChartCRDSkip ChartCRDLifecycle = "skip" - // ChartCRDUpgrade means the CRD is in the chart and Helm Upgrade will - // not process it. - ChartCRDUpgrade ChartCRDLifecycle = "upgrade" -) - -// ChartCRD is one chart CRD document for plan presentation. YAML is the -// original source document; it is not a live-object snapshot. -type ChartCRD struct { - // Source is the display path under .deployah/crds/. - Source string - // Index is the 0-based position among non-empty YAML documents in that - // file. Parse errors use a 1-based YAML document number that also - // counts empty documents. - Index int - Kind string - Name string - // Lifecycle is Helm's handling of this document in this invocation. - Lifecycle ChartCRDLifecycle - // WillProcess is true only when Lifecycle is [ChartCRDProcess]. - WillProcess bool - YAML string -} - -const ( - // TaskOnPreDeploy is a hook that runs before other resources. - TaskOnPreDeploy = "preDeploy" - // TaskOnPostDeploy is a hook that runs after the app is ready. - TaskOnPostDeploy = "postDeploy" - // TaskOnManual is a task that runs only via the CLI. - TaskOnManual = "manual" - // TaskOnSchedule is a task that runs as a Kubernetes CronJob. - TaskOnSchedule = "schedule" -) - -// PlannedTask is one spec task shown in the plan Tasks section. -type PlannedTask struct { - Name string - On string - Timeout string - HookWeight int - Manual bool -} - -// FirstInstallTaskNote returns a warning when this plan is a fresh install -// that includes a preDeploy task. Helm runs those hooks before other -// resources, so anything the task talks to (a database, a queue, another -// API) must already exist. It returns "" otherwise. -func (p *Plan) FirstInstallTaskNote() string { - if p == nil || !p.Header.FreshInstall { - return "" - } - for _, task := range p.Tasks { - if task.On == TaskOnPreDeploy { - return "preDeploy runs before other resources on a first install; anything it talks to must already be reachable." - } - } - return "" -} - -// HasChanges reports whether applying this plan would change the cluster: -// any resource-level change, a hook-only change, or chart CRDs Helm will -// process on this install. -func (p *Plan) HasChanges() bool { - if p == nil { - return false - } - return len(p.Changes) > 0 || p.HooksChanged || p.chartCRDsPending() -} - -func (p *Plan) chartCRDsPending() bool { - for _, c := range p.ChartCRDs { - if c.WillProcess { - return true - } - } - return false -} diff --git a/internal/plan/yamltree.go b/internal/plan/yamltree.go deleted file mode 100644 index b716e7d..0000000 --- a/internal/plan/yamltree.go +++ /dev/null @@ -1,241 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "fmt" - "io" - "strconv" - "strings" - - "nabat.dev/theme" -) - -// yamlTreeNode is one node in the nested tree ModeYAML builds from a -// Change's Fields, reconstructing the real manifest shape (map nesting and -// named list entries) instead of dyff's flattened dot path. -type yamlTreeNode struct { - children map[string]*yamlTreeNode - order []string // insertion order of children, for stable output - // listKey is non-empty when this node is one entry of a named-entry - // list (e.g. a container matched by its "name" field): it holds the - // identifying field name, and the node's own key (as stored in the - // parent's children map) is the identifier value. - listKey string - // field is set when a FieldDiff's path ends exactly at this node. - field *FieldDiff -} - -func newYAMLTreeNode() *yamlTreeNode { - return &yamlTreeNode{children: make(map[string]*yamlTreeNode)} -} - -// buildYAMLTree merges every field's Segments into one tree. -func buildYAMLTree(fields []FieldDiff) *yamlTreeNode { - root := newYAMLTreeNode() - for i := range fields { - insertYAMLField(root, fieldSegments(&fields[i]), &fields[i]) - } - return root -} - -// fieldSegments returns f.Segments, falling back to splitting f.Path into -// plain map-key segments when Segments wasn't populated (e.g. a Change -// built by hand rather than via [ComputeDiff]/dyff) -- without this, -// distinct hand-built fields would collide into one tree node. -func fieldSegments(f *FieldDiff) []PathSegment { - if len(f.Segments) > 0 || f.Path == "" { - return f.Segments - } - parts := strings.Split(f.Path, ".") - segments := make([]PathSegment, 0, len(parts)) - for _, part := range parts { - segments = append(segments, PathSegment{Name: part, Idx: -1}) - } - return segments -} - -func insertYAMLField(node *yamlTreeNode, segments []PathSegment, field *FieldDiff) { - if len(segments) == 0 { - // Truly empty path (no Segments AND no Path): attach directly so - // writeYAMLTree can still print it instead of silently dropping it. - node.field = field - return - } - - seg := segments[0] - key := seg.Name - if seg.ListKey == "" && seg.Name == "" { - key = strconv.Itoa(seg.Idx) - } - - child, ok := node.children[key] - if !ok { - child = newYAMLTreeNode() - node.children[key] = child - node.order = append(node.order, key) - } - if seg.ListKey != "" { - child.listKey = seg.ListKey - } - insertYAMLField(child, segments[1:], field) -} - -// yamlLeafStyle controls how a leaf's old/new values render as text, so -// the same tree structure serves both the ordinary diff ("X -> Y") and the -// drift section ("expected X, live Y"). -type yamlLeafStyle int - -const ( - yamlLeafStyleChange yamlLeafStyle = iota - yamlLeafStyleDrift -) - -// yamlTreeBaseIndent matches the 4-space (" ") indent every other mode -// uses for a Change's top-level fields. -const yamlTreeBaseIndent = 2 - -// writeYAMLTree renders fields as a nested YAML-shaped tree. -func writeYAMLTree(w io.Writer, fields []FieldDiff, opts TextOptions, style yamlLeafStyle) error { - root := buildYAMLTree(fields) - if root.field != nil { - if err := writeYAMLLeaf(w, yamlTreeBaseIndent, "(root)", *root.field, opts, style); err != nil { - return err - } - } - return writeYAMLChildren(w, root, yamlTreeBaseIndent, opts, style) -} - -func writeYAMLChildren(w io.Writer, node *yamlTreeNode, indent int, opts TextOptions, style yamlLeafStyle) error { - for _, key := range node.order { - if err := writeYAMLNode(w, key, node.children[key], indent, opts, style); err != nil { - return err - } - } - return nil -} - -func writeYAMLNode(w io.Writer, key string, node *yamlTreeNode, indent int, opts TextOptions, style yamlLeafStyle) error { - pad := strings.Repeat(" ", indent) - - if node.listKey != "" { - // A named list entry: "- : ", then either its - // remaining changed fields (a scalar field inside an existing - // item changed) or, when the whole item was added/removed as one - // unit, its content dumped directly underneath. - if _, err := fmt.Fprintf(w, "%s- %s: %s\n", pad, node.listKey, key); err != nil { - return err - } - childIndent := indent + 1 - if node.field != nil { - return writeYAMLValueBlock(w, childIndent, *node.field, opts, style) - } - return writeYAMLChildren(w, node, childIndent, opts, style) - } - - if node.field != nil && len(node.order) == 0 { - return writeYAMLLeaf(w, indent, key, *node.field, opts, style) - } - - if _, err := fmt.Fprintf(w, "%s%s:\n", pad, key); err != nil { - return err - } - return writeYAMLChildren(w, node, indent+1, opts, style) -} - -// writeYAMLValueBlock dumps a whole item's added/removed/changed content -// directly under its "- : " line, for a diff that reports -// an entire named list entry at once (e.g. a whole new container) rather -// than per-field. -func writeYAMLValueBlock(w io.Writer, indent int, f FieldDiff, opts TextOptions, style yamlLeafStyle) error { - pad := strings.Repeat(" ", indent) - if f.Masked && !opts.ShowSecrets { - line := opts.Theme.Style(theme.TextMuted).Render(fmt.Sprintf("%s(masked) %s", pad, f.ChangeKind)) - _, err := fmt.Fprintln(w, line) - return err - } - token := fieldToken(f.ChangeKind) - if style == yamlLeafStyleDrift { - token = theme.StatusWarning - } - render := opts.Theme.Style(token).Render - switch f.ChangeKind { - case FieldAdded: - _, err := fmt.Fprintln(w, render(indentBlock(f.New, pad))) - return err - case FieldRemoved: - _, err := fmt.Fprintln(w, render(indentBlock(f.Old, pad))) - return err - default: - if _, err := fmt.Fprintln(w, render(indentBlock(f.Old, pad+"- "))); err != nil { - return err - } - _, err := fmt.Fprintln(w, render(indentBlock(f.New, pad+"+ "))) - return err - } -} - -// writeYAMLLeaf renders one leaf field: "key: old -> new" (or the drift/ -// masked/added/removed variant), falling back to an indented block when -// the value itself is multi-line (a whole map/list replaced as a scalar -// field's value, e.g. a ConfigMap's whole data entry). -func writeYAMLLeaf(w io.Writer, indent int, key string, f FieldDiff, opts TextOptions, style yamlLeafStyle) error { - pad := strings.Repeat(" ", indent) - - if f.Masked && !opts.ShowSecrets { - line := opts.Theme.Style(theme.TextMuted).Render(fmt.Sprintf("%s%s: (masked) %s", pad, key, f.ChangeKind)) - _, err := fmt.Fprintln(w, line) - return err - } - - if strings.Contains(f.Old, "\n") || strings.Contains(f.New, "\n") { - if _, err := fmt.Fprintf(w, "%s%s:\n", pad, key); err != nil { - return err - } - return writeYAMLValueBlock(w, indent+1, f, opts, style) - } - - token := fieldToken(f.ChangeKind) - if style == yamlLeafStyleDrift { - token = theme.StatusWarning - } - render := opts.Theme.Style(token).Render - - if style == yamlLeafStyleDrift { - switch f.ChangeKind { - case FieldAdded: - _, err := fmt.Fprintln(w, render(fmt.Sprintf("%s%s: (only on cluster) %s", pad, key, f.New))) - return err - case FieldRemoved: - _, err := fmt.Fprintln(w, render(fmt.Sprintf("%s%s: (missing on cluster, expected %s)", pad, key, f.Old))) - return err - default: - _, err := fmt.Fprintln(w, render(fmt.Sprintf("%s%s: expected %s, live %s", pad, key, f.Old, f.New))) - return err - } - } - - switch f.ChangeKind { - case FieldAdded: - _, err := fmt.Fprintln(w, render(fmt.Sprintf("%s%s: (added) %s", pad, key, f.New))) - return err - case FieldRemoved: - _, err := fmt.Fprintln(w, render(fmt.Sprintf("%s%s: (removed) %s", pad, key, f.Old))) - return err - default: - _, err := fmt.Fprintln(w, render(fmt.Sprintf("%s%s: %s -> %s", pad, key, f.Old, f.New))) - return err - } -} diff --git a/internal/plan/yamltree_test.go b/internal/plan/yamltree_test.go deleted file mode 100644 index 47624ee..0000000 --- a/internal/plan/yamltree_test.go +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestBuildYAMLTree_SiblingsShareParent verifies fields that share a -// common parent path (e.g. several changed resources.requests keys) nest -// under one shared node instead of each repeating the full parent chain. -func TestBuildYAMLTree_SiblingsShareParent(t *testing.T) { - t.Parallel() - fields := []FieldDiff{ - { - Segments: []PathSegment{{Name: "resources", Idx: -1}, {Name: "requests", Idx: -1}, {Name: "cpu", Idx: -1}}, - ChangeKind: FieldChanged, Old: "500m", New: "200m", - }, - { - Segments: []PathSegment{{Name: "resources", Idx: -1}, {Name: "requests", Idx: -1}, {Name: "memory", Idx: -1}}, - ChangeKind: FieldChanged, Old: "1Gi", New: "512Mi", - }, - } - - root := buildYAMLTree(fields) - require.Len(t, root.order, 1, "both fields share the same top-level key") - resources := root.children["resources"] - require.NotNil(t, resources) - require.Len(t, resources.order, 1) - requests := resources.children["requests"] - require.NotNil(t, requests) - assert.ElementsMatch(t, []string{"cpu", "memory"}, requests.order) - assert.Equal(t, "500m", requests.children["cpu"].field.Old) - assert.Equal(t, "1Gi", requests.children["memory"].field.Old) -} - -// TestBuildYAMLTree_NamedListEntryMarksListKey verifies a segment with -// ListKey set (a named-entry list item, e.g. a container matched by -// "name") marks the resulting node so the renderer knows to print it as -// "- name: " rather than ":". -func TestBuildYAMLTree_NamedListEntryMarksListKey(t *testing.T) { - t.Parallel() - fields := []FieldDiff{ - { - Segments: []PathSegment{ - {Name: "containers", Idx: -1}, - {Name: "web", ListKey: "name", Idx: -1}, - {Name: "image", Idx: -1}, - }, - ChangeKind: FieldChanged, Old: "a:v1", New: "a:v2", - }, - } - - root := buildYAMLTree(fields) - containers := root.children["containers"] - require.NotNil(t, containers) - web := containers.children["web"] - require.NotNil(t, web) - assert.Equal(t, "name", web.listKey) - assert.NotNil(t, web.children["image"].field) -} - -// TestFieldSegments_FallsBackToDotPathWithoutCollision verifies two fields -// with no Segments (e.g. a hand-built Change, bypassing ComputeDiff) still -// both appear in the tree instead of colliding into a single node -- the -// bug the dot-path fallback in fieldSegments exists to prevent. -func TestFieldSegments_FallsBackToDotPathWithoutCollision(t *testing.T) { - t.Parallel() - fields := []FieldDiff{ - {Path: "spec.replicas", ChangeKind: FieldChanged, Old: "2", New: "3"}, - {Path: "spec.paused", ChangeKind: FieldChanged, Old: "true", New: "false"}, - } - - var buf strings.Builder - require.NoError(t, writeYAMLTree(&buf, fields, TextOptions{}, yamlLeafStyleChange)) - - got := buf.String() - assert.Contains(t, got, "replicas: 2 -> 3") - assert.Contains(t, got, "paused: true -> false") -} - -// TestFieldSegments_PopulatedSegmentsTakePriority verifies fieldSegments -// prefers a field's real Segments over re-deriving from Path when both -// are present (Segments always wins; Path is only a fallback source). -func TestFieldSegments_PopulatedSegmentsTakePriority(t *testing.T) { - t.Parallel() - f := &FieldDiff{ - Path: "a.b.c", - Segments: []PathSegment{{Name: "x", Idx: -1}}, - } - segments := fieldSegments(f) - require.Len(t, segments, 1) - assert.Equal(t, "x", segments[0].Name) -} diff --git a/internal/render/result.go b/internal/render/result.go index e881012..8235f5f 100644 --- a/internal/render/result.go +++ b/internal/render/result.go @@ -23,9 +23,9 @@ import ( ) // RenderResult is the client-side render of a chart for one project and -// environment. deployah plan diffs it against the last successful -// release. Deploy renders the same way before a volume resize, then -// drops the result, so a chart that does not render never gets resized. +// environment. deployah plan compares it with the previous release baseline +// selected by Helm. Deploy renders the same way before a volume resize, +// then drops the result, so a chart that does not render never gets resized. type RenderResult struct { // ReleaseName is the Helm release name computed for project/environment. ReleaseName string diff --git a/internal/testing/plan_integration_test.go b/internal/testing/plan_integration_test.go deleted file mode 100644 index 5ab793e..0000000 --- a/internal/testing/plan_integration_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build integration - -package testing - -import ( - "os" - "testing" -) - -// TestPlanScenarios runs every scenarios/plan-* scenario through the plan -// engine and checks the result against plan-config.yaml and any golden files. -func TestPlanScenarios(t *testing.T) { - if _, err := os.Stat(TestScenariosDir); err != nil { - t.Fatalf("test scenarios directory %s is required: %v", TestScenariosDir, err) - } - - scenarios, err := DiscoverPlanScenarios(TestScenariosDir) - if err != nil { - t.Fatalf("Failed to discover plan scenarios: %v", err) - } - - if len(scenarios) == 0 { - t.Fatalf("no plan scenarios found under %s; tracked plan-* fixtures are required", TestScenariosDir) - } - - t.Logf("Found %d plan scenarios", len(scenarios)) - - for _, scenario := range scenarios { - RunPlanScenarioTest(t, scenario) - } -} diff --git a/internal/testing/plan_scenarios.go b/internal/testing/plan_scenarios.go deleted file mode 100644 index 5cc8f54..0000000 --- a/internal/testing/plan_scenarios.go +++ /dev/null @@ -1,353 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package testing - -import ( - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.yaml.in/yaml/v3" - - "deployah.dev/deployah/internal/extras" - "deployah.dev/deployah/internal/k8s" - "deployah.dev/deployah/internal/plan" - "deployah.dev/deployah/internal/spec" - - v1 "helm.sh/helm/v4/pkg/release/v1" -) - -// PlanTestScenario describes one scenarios/plan-* directory: a before/after -// manifest pair (however each side is sourced) that [RunPlanScenarioTest] -// diffs through the real [plan.ComputeDiff] engine and checks against -// plan-config.yaml. -type PlanTestScenario struct { - // Name is the scenario directory name (e.g. "plan-image-bump"). - Name string - // Dir is the scenario's absolute directory path. - Dir string -} - -// PlanConfig is the structural expectation a scenario author writes by -// hand in plan-config.yaml: what [plan.ComputeDiff] must produce for this -// scenario's before/after pair, independent of how the text or JSON -// renderers format it. -type PlanConfig struct { - // FreshInstall selects an empty previous manifest instead of resolving - // one from before.yaml or previous.yaml, for the fresh-install case - // where neither file is expected to exist. - FreshInstall bool `yaml:"freshInstall"` - // Changes lists every expected change, in the exact order - // [plan.ComputeDiff] returns them (sorted by Kind, then Name). - Changes []PlanConfigChange `yaml:"changes"` - // Summary is the expected change tally. - Summary PlanConfigSummary `yaml:"summary"` - // HooksChanged asserts [plan.Plan.HooksChanged]. True when hook tasks - // are present on a fresh install (Helm reports hook manifests). - HooksChanged bool `yaml:"hooksChanged"` - // Masked lists field paths (as they appear in a Changes[].Fields[] - // entry's Path) that [plan.ApplyMasking] must flag as masked. Every - // masked path [RunPlanScenarioTest] finds in the computed plan must - // appear here, and vice versa. - Masked []string `yaml:"masked"` - // Warning is documentary only: this offline harness has no live/faked - // release store to compute a real warning from ([plan.LastSuccessfulRelease] - // is covered separately in history_test.go), so the test runner just - // sets Header.Warning to this value before checking it. - Warning string `yaml:"warning"` -} - -// PlanConfigChange is one expected entry in [PlanConfig.Changes]. -type PlanConfigChange struct { - Action string `yaml:"action"` - Kind string `yaml:"kind"` - Name string `yaml:"name"` - Fields []PlanConfigField `yaml:"fields"` -} - -// PlanConfigField is one expected field-level difference within a -// [PlanConfigChange]. Old is empty for an added field; New is empty for a -// removed field, mirroring [plan.FieldDiff]. -type PlanConfigField struct { - Path string `yaml:"path"` - Old string `yaml:"old"` - New string `yaml:"new"` -} - -// PlanConfigSummary is the expected [plan.Summary]. -type PlanConfigSummary struct { - Add int `yaml:"add"` - Change int `yaml:"change"` - Destroy int `yaml:"destroy"` -} - -// DiscoverPlanScenarios finds every scenarios/plan-* directory with a -// plan-config.yaml. Unlike [DiscoverScenarios], deployah.yaml is optional: -// [RunPlanScenarioTest] falls back to a raw current.yaml for kinds the -// spec/chart pipeline can't produce (e.g. a Secret). -func DiscoverPlanScenarios(scenariosDir string) ([]PlanTestScenario, error) { - entries, err := os.ReadDir(scenariosDir) - if err != nil { - return nil, fmt.Errorf("reading scenarios directory: %w", err) - } - - var scenarios []PlanTestScenario - for _, entry := range entries { - if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "plan-") { - continue - } - dir := filepath.Join(scenariosDir, entry.Name()) - if _, statErr := os.Stat(filepath.Join(dir, "plan-config.yaml")); errors.Is(statErr, fs.ErrNotExist) { - continue - } - scenarios = append(scenarios, PlanTestScenario{Name: entry.Name(), Dir: dir}) - } - - return scenarios, nil -} - -// RunPlanScenarioTest resolves scenario's previous/current manifest pair, -// computes the diff through the real plan engine, and checks the result -// against plan-config.yaml (and, when present, golden.txt/golden.json). -func RunPlanScenarioTest(t *testing.T, scenario PlanTestScenario) { - t.Helper() - t.Run(scenario.Name, func(t *testing.T) { - cfg := loadPlanConfig(t, filepath.Join(scenario.Dir, "plan-config.yaml")) - - previous := resolvePreviousSide(t, scenario.Dir, cfg) - current := resolveCurrentSide(t, scenario.Dir) - - p, err := plan.ComputeDiff(previous.Manifest, current.Manifest) - require.NoError(t, err) - p.HooksChanged = plan.HooksChanged(previous.Hooks, current.Hooks) - p.Header = plan.Header{ - Project: current.Project, - Environment: current.Environment, - Release: current.ReleaseName, - Namespace: current.Namespace, - FreshInstall: previous.Manifest == "", - } - p.Tasks = current.Tasks - if cfg.Warning != "" { - p.Header.Warning = cfg.Warning - } - plan.ApplyMasking(p) - - assertPlanMatchesConfig(t, p, cfg) - checkTextGolden(t, scenario.Dir, p) - checkJSONGolden(t, scenario.Dir, p) - }) -} - -// manifestSide is one side (previous or current) of a plan scenario's diff -// input, plus [plan.Header] metadata populated when it came from rendering -// a spec file; those fields stay zero for a raw manifest file. -type manifestSide struct { - Manifest string - Hooks []*v1.Hook - Project string - Environment string - ReleaseName string - Namespace string - Tasks []plan.PlannedTask -} - -// resolvePreviousSide resolves a scenario's previous manifest: a raw -// previous.yaml (for cases a chart can't render, e.g. simulated noise -// fields) takes precedence, then a rendered before.yaml, then an empty -// manifest when plan-config.yaml declares freshInstall. Fails the test if -// none apply. -func resolvePreviousSide(t *testing.T, dir string, cfg PlanConfig) manifestSide { - t.Helper() - if side, ok := readRawManifestFile(t, filepath.Join(dir, "previous.yaml")); ok { - return side - } - beforePath := filepath.Join(dir, "before.yaml") - if _, err := os.Stat(beforePath); err == nil { - return renderManifestFile(t, dir, "before.yaml") - } - if !cfg.FreshInstall { - t.Fatalf("scenario %s: no previous.yaml or before.yaml, and freshInstall is not set in plan-config.yaml", dir) - } - return manifestSide{} -} - -// resolveCurrentSide resolves a scenario's current manifest: a raw -// current.yaml takes precedence (for resource kinds the spec/chart -// pipeline cannot produce, e.g. a bare Secret), otherwise deployah.yaml is -// rendered. -func resolveCurrentSide(t *testing.T, dir string) manifestSide { - t.Helper() - if side, ok := readRawManifestFile(t, filepath.Join(dir, "current.yaml")); ok { - return side - } - return renderManifestFile(t, dir, "deployah.yaml") -} - -// readRawManifestFile reads path as a raw, already-rendered Kubernetes -// manifest. It reports false (with a zero manifestSide) when path does not -// exist, and fails the test on any other read error. -func readRawManifestFile(t *testing.T, path string) (manifestSide, bool) { - t.Helper() - data, err := os.ReadFile(path) // #nosec G304 -- path built from scenario discovery under scenarios/ - if errors.Is(err, fs.ErrNotExist) { - return manifestSide{}, false - } - require.NoError(t, err) - return manifestSide{Manifest: string(data)}, true -} - -// renderManifestFile loads dir/filename and renders it without Kubernetes -// access. -func renderManifestFile(t *testing.T, dir, filename string) manifestSide { - t.Helper() - ctx := t.Context() - specPath := filepath.Join(dir, filename) - - var platform *spec.PlatformConfig - platformPath := filepath.Join(dir, spec.DefaultPlatformPath) - if _, statErr := os.Stat(platformPath); statErr == nil { - loaded, loadErr := spec.LoadPlatform(platformPath) - require.NoError(t, loadErr) - platform = loaded - } - - manifest, substReport, err := spec.Load(ctx, specPath, "", platform) - require.NoError(t, err) - envName, _, err := spec.ResolveEnvironment(manifest.Environments, platform, "") - require.NoError(t, err) - - // Resolve even when platform is nil so runtime env reaches the chart. - envIdentity := spec.NormalizeEnv(envName) - resolved, _, resolveErr := spec.Resolve(manifest, platform, envIdentity, substReport) - require.NoError(t, resolveErr) - require.NoError(t, k8s.MaterializeSelfSignedTLS(ctx, nil, "", resolved)) - - bundle, loadErr := extras.LoadFromSpec(specPath, manifest, platform, envName, fixtureNamespace, nil) - require.NoError(t, loadErr) - - result, err := renderFixtureChart(t, resolved, bundle.PostRendererFor(), bundle.CRDs) - require.NoError(t, err) - - return manifestSide{ - Manifest: result.Manifest, - Hooks: result.Hooks, - Project: manifest.Project, - Environment: envName, - ReleaseName: result.ReleaseName, - Namespace: result.Namespace, - Tasks: mustPlanTasks(t, manifest, envName, resolved), - } -} - -// loadPlanConfig reads and parses a scenario's plan-config.yaml. -func loadPlanConfig(t *testing.T, path string) PlanConfig { - t.Helper() - data, err := os.ReadFile(path) // #nosec G304 -- path built from scenario discovery under scenarios/ - require.NoError(t, err) - - var cfg PlanConfig - require.NoError(t, yaml.Unmarshal(data, &cfg)) - return cfg -} - -// assertPlanMatchesConfig checks the computed plan p against every -// structural expectation declared in cfg. -func assertPlanMatchesConfig(t *testing.T, p *plan.Plan, cfg PlanConfig) { - t.Helper() - - require.Len(t, p.Changes, len(cfg.Changes), "change count mismatch") - for i, want := range cfg.Changes { - got := p.Changes[i] - assert.Equal(t, want.Action, string(got.Action), "changes[%d].action", i) - assert.Equal(t, want.Kind, got.Kind, "changes[%d].kind", i) - assert.Equal(t, want.Name, got.Name, "changes[%d].name", i) - - require.Len(t, got.Fields, len(want.Fields), "changes[%d].fields count mismatch", i) - for j, wantField := range want.Fields { - gotField := got.Fields[j] - assert.Equal(t, wantField.Path, gotField.Path, "changes[%d].fields[%d].path", i, j) - assert.Equal(t, wantField.Old, gotField.Old, "changes[%d].fields[%d].old", i, j) - assert.Equal(t, wantField.New, gotField.New, "changes[%d].fields[%d].new", i, j) - } - } - - assert.Equal(t, cfg.Summary.Add, p.Summary.Add, "summary.add") - assert.Equal(t, cfg.Summary.Change, p.Summary.Change, "summary.change") - assert.Equal(t, cfg.Summary.Destroy, p.Summary.Destroy, "summary.destroy") - assert.Equal(t, cfg.HooksChanged, p.HooksChanged, "hooksChanged") - - if cfg.Warning != "" { - assert.Contains(t, p.Header.Warning, cfg.Warning, "header.warning") - } - - assert.ElementsMatch(t, cfg.Masked, maskedFieldPaths(p), "masked field paths") -} - -// maskedFieldPaths collects the Path of every FieldDiff [plan.ApplyMasking] -// flagged as masked, across all of p's changes. -func maskedFieldPaths(p *plan.Plan) []string { - var paths []string - for _, c := range p.Changes { - for _, f := range c.Fields { - if f.Masked { - paths = append(paths, f.Path) - } - } - } - return paths -} - -// checkTextGolden compares [plan.RenderText]'s output for p against -// dir/golden.txt when that file exists; scenarios without a golden.txt -// only run the structural checks in [assertPlanMatchesConfig]. -func checkTextGolden(t *testing.T, dir string, p *plan.Plan) { - t.Helper() - goldenPath := filepath.Join(dir, "golden.txt") - if _, err := os.Stat(goldenPath); errors.Is(err, fs.ErrNotExist) { - return - } - - var buf strings.Builder - require.NoError(t, plan.RenderText(&buf, p, plan.TextOptions{})) - compareOrUpdateGolden(t, goldenPath, buf.String()) -} - -// checkJSONGolden is [checkTextGolden]'s JSON counterpart, comparing -// [plan.RenderJSON]'s output against dir/golden.json when it exists. -func checkJSONGolden(t *testing.T, dir string, p *plan.Plan) { - t.Helper() - goldenPath := filepath.Join(dir, "golden.json") - if _, err := os.Stat(goldenPath); errors.Is(err, fs.ErrNotExist) { - return - } - - var buf strings.Builder - require.NoError(t, plan.RenderJSON(&buf, p)) - compareOrUpdateGolden(t, goldenPath, buf.String()) -} - -func mustPlanTasks(t *testing.T, manifest *spec.Spec, environment string, resolved *spec.ResolvedSpec) []plan.PlannedTask { - t.Helper() - tasks, err := plan.TasksFromSpec(manifest, environment, resolved) - require.NoError(t, err) - return tasks -} diff --git a/internal/testing/scenario_discovery.go b/internal/testing/scenario_discovery.go index 4f8c70e..94bad19 100644 --- a/internal/testing/scenario_discovery.go +++ b/internal/testing/scenario_discovery.go @@ -59,14 +59,6 @@ func DiscoverScenarios(scenariosDir string) ([]TestScenario, error) { return filepath.SkipDir } - // plan-* directories hold plan-config.yaml scenarios (see - // plan_scenarios.go), not render/golden-file scenarios: skip them - // here so they never get treated as a render scenario missing its - // expected/ directory. - if strings.HasPrefix(info.Name(), "plan-") { - return filepath.SkipDir - } - manifestPath := filepath.Join(path, "deployah.yaml") if _, statErr := os.Stat(manifestPath); errors.Is(statErr, fs.ErrNotExist) { return nil diff --git a/scenarios/plan-after-failed-upgrade/deployah.yaml b/scenarios/plan-after-failed-upgrade/deployah.yaml deleted file mode 100644 index 54bf703..0000000 --- a/scenarios/plan-after-failed-upgrade/deployah.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-after-failed-upgrade -components: - web: - image: my-app:1.0.1 - port: 8080 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-after-failed-upgrade/plan-config.yaml b/scenarios/plan-after-failed-upgrade/plan-config.yaml deleted file mode 100644 index f0c5496..0000000 --- a/scenarios/plan-after-failed-upgrade/plan-config.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# previous.yaml stands in for revision 7, the last release that actually -# applied (a since-abandoned revision 8 upgrade attempt is never -# represented as a file here: BuildPlan would have walked past it and -# landed on this same manifest, per plan.LastSuccessfulRelease). It also -# carries live-object noise fields (resourceVersion, uid, generation, -# creationTimestamp, managedFields, status, Helm/kubectl annotations) that -# must not leak into the diff as phantom changes. deployah.yaml is the fix: -# only the image tag differs. -freshInstall: false - -changes: - - action: change - kind: Deployment - name: plan-after-failed-upgrade-dev-web - fields: - - path: spec.template.spec.containers.web.image - old: "docker.io/library/my-app:1.0.0" - new: "docker.io/library/my-app:1.0.1" - -summary: - add: 0 - change: 1 - destroy: 0 - -# Documentary only: the real warning is computed by walking Helm release -# history (plan.LastSuccessfulRelease), which needs a live or faked -# release store this offline harness does not have. See history_test.go. -warning: "revision 8" diff --git a/scenarios/plan-after-failed-upgrade/previous.yaml b/scenarios/plan-after-failed-upgrade/previous.yaml deleted file mode 100644 index 5aca435..0000000 --- a/scenarios/plan-after-failed-upgrade/previous.yaml +++ /dev/null @@ -1,144 +0,0 @@ -# Raw manifest for the last *successful* release (revision 7), shaped the -# way Helm's release storage actually looks: it carries the bookkeeping -# fields a live/stored object has (resourceVersion, uid, generation, -# creationTimestamp, managedFields, status, and Helm/kubectl noise -# annotations) that normalizeResource must strip before diffing, and -# nothing here should be confused with a real spec-driven change. A later, -# failed revision 8 upgrade attempt is not represented here at all: the -# plan compares against this last-good manifest, not the failed one, so -# the fix in deployah.yaml is the only field difference. -apiVersion: apps/v1 -kind: Deployment -metadata: - name: plan-after-failed-upgrade-dev-web - namespace: default - resourceVersion: "12345" - uid: 8f14e45f-ceea-4e77-b1c4-1c6a3c3f0a1b - generation: 3 - creationTimestamp: "2024-01-01T00:00:00Z" - managedFields: - - manager: helm - operation: Update - annotations: - deployah.dev/environment-instance: dev - deployah.dev/project: plan-after-failed-upgrade - deployah.dev/source: spec - meta.helm.sh/release-name: plan-after-failed-upgrade-dev - meta.helm.sh/release-namespace: default - kubectl.kubernetes.io/last-applied-configuration: "{}" - labels: - app.kubernetes.io/instance: plan-after-failed-upgrade-dev - app.kubernetes.io/managed-by: Helm - app.kubernetes.io/name: web - deployah.dev/component: web - deployah.dev/environment: dev - deployah.dev/instance: plan-after-failed-upgrade-dev - deployah.dev/project: plan-after-failed-upgrade - helm.sh/chart: web-0.1.0 -status: - replicas: 1 - availableReplicas: 1 -spec: - replicas: 1 - revisionHistoryLimit: 10 - selector: - matchLabels: - app.kubernetes.io/instance: plan-after-failed-upgrade-dev - app.kubernetes.io/name: web - strategy: - type: RollingUpdate - template: - metadata: - annotations: - deployah.dev/environment-instance: dev - labels: - app.kubernetes.io/instance: plan-after-failed-upgrade-dev - app.kubernetes.io/managed-by: Helm - app.kubernetes.io/name: web - deployah.dev/component: web - deployah.dev/environment: dev - deployah.dev/instance: plan-after-failed-upgrade-dev - deployah.dev/project: plan-after-failed-upgrade - helm.sh/chart: web-0.1.0 - spec: - affinity: - podAntiAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - podAffinityTerm: - labelSelector: - matchLabels: - app.kubernetes.io/instance: plan-after-failed-upgrade-dev - app.kubernetes.io/name: web - topologyKey: kubernetes.io/hostname - weight: 1 - containers: - - image: docker.io/library/my-app:1.0.0 - imagePullPolicy: IfNotPresent - livenessProbe: - failureThreshold: 6 - periodSeconds: 10 - tcpSocket: - port: http - timeoutSeconds: 3 - name: web - ports: - - containerPort: 8080 - name: http - protocol: TCP - readinessProbe: - failureThreshold: 3 - periodSeconds: 5 - tcpSocket: - port: http - timeoutSeconds: 3 - resources: - limits: {} - requests: - cpu: 500m - ephemeral-storage: 50Mi - memory: 512Mi - startupProbe: - failureThreshold: 36 - periodSeconds: 5 - tcpSocket: - port: http - timeoutSeconds: 3 - restartPolicy: Always - serviceAccountName: default - terminationGracePeriodSeconds: 30 ---- -apiVersion: v1 -kind: Service -metadata: - name: plan-after-failed-upgrade-dev-web - namespace: default - resourceVersion: "12346" - uid: 8f14e45f-ceea-4e77-b1c4-1c6a3c3f0a1c - generation: 1 - creationTimestamp: "2024-01-01T00:00:00Z" - annotations: - deployah.dev/environment-instance: dev - deployah.dev/project: plan-after-failed-upgrade - deployah.dev/source: spec - meta.helm.sh/release-name: plan-after-failed-upgrade-dev - meta.helm.sh/release-namespace: default - labels: - app.kubernetes.io/instance: plan-after-failed-upgrade-dev - app.kubernetes.io/managed-by: Helm - app.kubernetes.io/name: web - deployah.dev/component: web - deployah.dev/environment: dev - deployah.dev/instance: plan-after-failed-upgrade-dev - deployah.dev/project: plan-after-failed-upgrade - helm.sh/chart: web-0.1.0 -spec: - ports: - - name: http - port: 80 - protocol: TCP - targetPort: http - selector: - app.kubernetes.io/instance: plan-after-failed-upgrade-dev - app.kubernetes.io/name: web - sessionAffinity: None - type: ClusterIP diff --git a/scenarios/plan-command-change/before.yaml b/scenarios/plan-command-change/before.yaml deleted file mode 100644 index 4f03ac8..0000000 --- a/scenarios/plan-command-change/before.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-command-change -components: - api: - image: ghcr.io/acme/api:1.0.0 - port: 8080 - resourcePreset: small - environments: [production] - command: ["/bin/api"] - args: ["--mode=serve", "--workers=2"] -environments: - production: {} diff --git a/scenarios/plan-command-change/deployah.yaml b/scenarios/plan-command-change/deployah.yaml deleted file mode 100644 index 7d52c3d..0000000 --- a/scenarios/plan-command-change/deployah.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-command-change -components: - api: - image: ghcr.io/acme/api:1.0.0 - port: 8080 - resourcePreset: small - environments: [production] - command: ["/bin/api"] - args: ["--mode=serve", "--workers=4"] -environments: - production: {} diff --git a/scenarios/plan-command-change/plan-config.yaml b/scenarios/plan-command-change/plan-config.yaml deleted file mode 100644 index 8499e83..0000000 --- a/scenarios/plan-command-change/plan-config.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# before.yaml and deployah.yaml differ only in the api component's last args -# entry ("--workers=2" -> "--workers=4"). args is an unnamed scalar list (no -# identifying key like a container's "name"), so dyff can't treat this as an -# in-place modification of one element: it reports the differing value as a -# removal of the old entry and an addition of the new one, both anchored at -# the whole-list path, rather than a single field.path with old/new set. -freshInstall: false - -changes: - - action: change - kind: Deployment - name: plan-command-change-production-api - fields: - - path: spec.template.spec.containers.api.args - old: "- --workers=2" - new: "" - - path: spec.template.spec.containers.api.args - old: "" - new: "- --workers=4" - -summary: - add: 0 - change: 1 - destroy: 0 diff --git a/scenarios/plan-extras-fresh-install/.deployah/manifests/networkpolicy.yaml b/scenarios/plan-extras-fresh-install/.deployah/manifests/networkpolicy.yaml deleted file mode 100644 index 6a04ade..0000000 --- a/scenarios/plan-extras-fresh-install/.deployah/manifests/networkpolicy.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: plan-extras-deny -spec: - podSelector: - matchLabels: - app.kubernetes.io/name: web - policyTypes: - - Ingress diff --git a/scenarios/plan-extras-fresh-install/deployah.yaml b/scenarios/plan-extras-fresh-install/deployah.yaml deleted file mode 100644 index f187dce..0000000 --- a/scenarios/plan-extras-fresh-install/deployah.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-extras-fresh-install -components: - web: - image: nginx:latest - port: 8080 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-extras-fresh-install/plan-config.yaml b/scenarios/plan-extras-fresh-install/plan-config.yaml deleted file mode 100644 index d86d3cd..0000000 --- a/scenarios/plan-extras-fresh-install/plan-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Fresh install: generated resources plus the extra NetworkPolicy appear as adds. -freshInstall: true - -changes: - - action: add - kind: Deployment - name: plan-extras-fresh-install-dev-web - - action: add - kind: NetworkPolicy - name: plan-extras-deny - - action: add - kind: Service - name: plan-extras-fresh-install-dev-web - -summary: - add: 3 - change: 0 - destroy: 0 diff --git a/scenarios/plan-fresh-install/deployah.yaml b/scenarios/plan-fresh-install/deployah.yaml deleted file mode 100644 index f169776..0000000 --- a/scenarios/plan-fresh-install/deployah.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-fresh-install -components: - web: - image: nginx:latest - port: 8080 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-fresh-install/plan-config.yaml b/scenarios/plan-fresh-install/plan-config.yaml deleted file mode 100644 index 45044f5..0000000 --- a/scenarios/plan-fresh-install/plan-config.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# No prior release exists, so every resource in the current render shows -# as an addition. -freshInstall: true - -changes: - - action: add - kind: Deployment - name: plan-fresh-install-dev-web - - action: add - kind: Service - name: plan-fresh-install-dev-web - -summary: - add: 2 - change: 0 - destroy: 0 diff --git a/scenarios/plan-hpa-change/before.yaml b/scenarios/plan-hpa-change/before.yaml deleted file mode 100644 index 8e589f8..0000000 --- a/scenarios/plan-hpa-change/before.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-hpa-change -components: - api: - image: ghcr.io/acme/api:1.0.0 - port: 8080 - resourcePreset: small - environments: [production] - autoscaling: - enabled: true - minReplicas: 2 - maxReplicas: 5 - metrics: - - type: cpu - target: 75 -environments: - production: {} diff --git a/scenarios/plan-hpa-change/deployah.yaml b/scenarios/plan-hpa-change/deployah.yaml deleted file mode 100644 index 3caf963..0000000 --- a/scenarios/plan-hpa-change/deployah.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-hpa-change -components: - api: - image: ghcr.io/acme/api:1.0.0 - port: 8080 - resourcePreset: small - environments: [production] - autoscaling: - enabled: true - minReplicas: 2 - maxReplicas: 10 - metrics: - - type: cpu - target: 75 -environments: - production: {} diff --git a/scenarios/plan-hpa-change/plan-config.yaml b/scenarios/plan-hpa-change/plan-config.yaml deleted file mode 100644 index 4cbad2c..0000000 --- a/scenarios/plan-hpa-change/plan-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# before.yaml and deployah.yaml differ only in the api component's -# autoscaling.maxReplicas (5 -> 10): a single field-level change on the -# existing HorizontalPodAutoscaler, no other resources affected. -freshInstall: false - -changes: - - action: change - kind: HorizontalPodAutoscaler - name: plan-hpa-change-production-api - fields: - - path: spec.maxReplicas - old: "5" - new: "10" - -summary: - add: 0 - change: 1 - destroy: 0 diff --git a/scenarios/plan-image-bump/before.yaml b/scenarios/plan-image-bump/before.yaml deleted file mode 100644 index f06cf82..0000000 --- a/scenarios/plan-image-bump/before.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-image-bump -components: - web: - image: nginx:1.25 - port: 8080 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-image-bump/deployah.yaml b/scenarios/plan-image-bump/deployah.yaml deleted file mode 100644 index a131f71..0000000 --- a/scenarios/plan-image-bump/deployah.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-image-bump -components: - web: - image: nginx:1.26 - port: 8080 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-image-bump/plan-config.yaml b/scenarios/plan-image-bump/plan-config.yaml deleted file mode 100644 index 1438f01..0000000 --- a/scenarios/plan-image-bump/plan-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# before.yaml and deployah.yaml differ only in the web component's image -# tag: a pure image bump on an existing Deployment, no other resources -# affected. -freshInstall: false - -changes: - - action: change - kind: Deployment - name: plan-image-bump-dev-web - fields: - - path: spec.template.spec.containers.web.image - old: "docker.io/library/nginx:1.25" - new: "docker.io/library/nginx:1.26" - -summary: - add: 0 - change: 1 - destroy: 0 diff --git a/scenarios/plan-ingress-added/before.yaml b/scenarios/plan-ingress-added/before.yaml deleted file mode 100644 index aceba65..0000000 --- a/scenarios/plan-ingress-added/before.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-ingress-added -components: - api: - image: ghcr.io/acme/api:1.0.0 - port: 8080 - resourcePreset: small - environments: [production] -environments: - production: {} diff --git a/scenarios/plan-ingress-added/deployah.platform.yaml b/scenarios/plan-ingress-added/deployah.platform.yaml deleted file mode 100644 index 49d939d..0000000 --- a/scenarios/plan-ingress-added/deployah.platform.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# $schema: ../../internal/spec/schema/platform/v1-alpha.3/platform.json -apiVersion: platform/v1-alpha.3 -environments: - production: - domains: - public: - baseDomain: example.com - tls: - mode: secretName - secretName: wildcard-example-com-tls diff --git a/scenarios/plan-ingress-added/deployah.yaml b/scenarios/plan-ingress-added/deployah.yaml deleted file mode 100644 index 8a8f050..0000000 --- a/scenarios/plan-ingress-added/deployah.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-ingress-added -components: - api: - image: ghcr.io/acme/api:1.0.0 - port: 8080 - resourcePreset: small - environments: [production] - expose: {} -environments: - production: {} diff --git a/scenarios/plan-ingress-added/plan-config.yaml b/scenarios/plan-ingress-added/plan-config.yaml deleted file mode 100644 index b5c44d2..0000000 --- a/scenarios/plan-ingress-added/plan-config.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# before.yaml has no "expose"; deployah.yaml adds a bare "expose: {}" with a -# secretName-mode platform domain, so the diff adds exactly one new Ingress -# and leaves the existing Deployment/Service untouched. secretName mode (vs. -# selfSigned) keeps the diff deterministic: no generated cert/key noise. -freshInstall: false - -changes: - - action: add - kind: Ingress - name: plan-ingress-added-production-api - -summary: - add: 1 - change: 0 - destroy: 0 diff --git a/scenarios/plan-mixed-changes/before.yaml b/scenarios/plan-mixed-changes/before.yaml deleted file mode 100644 index 8b5e6b3..0000000 --- a/scenarios/plan-mixed-changes/before.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-mixed-changes -components: - web: - image: nginx:1.25 - port: 8080 - environments: [dev] - resourcePreset: small - legacy: - image: legacy-app:1.0.0 - port: 9000 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-mixed-changes/deployah.yaml b/scenarios/plan-mixed-changes/deployah.yaml deleted file mode 100644 index 87b1273..0000000 --- a/scenarios/plan-mixed-changes/deployah.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-mixed-changes -components: - web: - image: nginx:1.26 - port: 8080 - environments: [dev] - resourcePreset: small - api: - image: my-app:1.0.0 - port: 9090 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-mixed-changes/golden.json b/scenarios/plan-mixed-changes/golden.json deleted file mode 100644 index 08fcfc4..0000000 --- a/scenarios/plan-mixed-changes/golden.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "format_version": "1.1", - "project": "plan-mixed-changes", - "environment": "dev", - "release": "plan-mixed-changes-dev", - "namespace": "default", - "context": "", - "revision": null, - "fresh_install": false, - "changes": [ - { - "action": "add", - "kind": "Deployment", - "api_version": "apps/v1", - "name": "plan-mixed-changes-dev-api", - "namespace": "default", - "fields": [] - }, - { - "action": "destroy", - "kind": "Deployment", - "api_version": "apps/v1", - "name": "plan-mixed-changes-dev-legacy", - "namespace": "default", - "fields": [] - }, - { - "action": "change", - "kind": "Deployment", - "api_version": "apps/v1", - "name": "plan-mixed-changes-dev-web", - "namespace": "default", - "fields": [ - { - "path": "spec.template.spec.containers.web.image", - "old": "docker.io/library/nginx:1.25", - "new": "docker.io/library/nginx:1.26" - } - ] - }, - { - "action": "add", - "kind": "Service", - "api_version": "v1", - "name": "plan-mixed-changes-dev-api", - "namespace": "default", - "fields": [] - }, - { - "action": "destroy", - "kind": "Service", - "api_version": "v1", - "name": "plan-mixed-changes-dev-legacy", - "namespace": "default", - "fields": [] - } - ], - "summary": { - "add": 2, - "change": 1, - "destroy": 2 - } -} diff --git a/scenarios/plan-mixed-changes/golden.txt b/scenarios/plan-mixed-changes/golden.txt deleted file mode 100644 index 968dc41..0000000 --- a/scenarios/plan-mixed-changes/golden.txt +++ /dev/null @@ -1,13 +0,0 @@ -Project: plan-mixed-changes -Environment: dev -Release: plan-mixed-changes-dev -Namespace: default - -+ Deployment/plan-mixed-changes-dev-api -- Deployment/plan-mixed-changes-dev-legacy -~ Deployment/plan-mixed-changes-dev-web - image: docker.io/library/nginx:1.25 -> docker.io/library/nginx:1.26 -+ Service/plan-mixed-changes-dev-api -- Service/plan-mixed-changes-dev-legacy - -Plan: 2 to add, 1 to change, 2 to destroy. diff --git a/scenarios/plan-mixed-changes/plan-config.yaml b/scenarios/plan-mixed-changes/plan-config.yaml deleted file mode 100644 index b8ab449..0000000 --- a/scenarios/plan-mixed-changes/plan-config.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# Showcase scenario exercising add + change + destroy in a single diff: -# before.yaml drops "api" and adds "legacy" relative to deployah.yaml, -# while "web" keeps its name and only bumps its image tag. Changes are -# sorted by Kind then Name (see plan.ComputeDiff), not grouped by action, -# so the order below interleaves add/change/destroy within each Kind. -# This is also the only scenario with golden.txt/golden.json: regenerate -# them with `go test -tags integration -update ./internal/testing/...` -# after any intentional change to this scenario or the renderers. -freshInstall: false - -changes: - - action: add - kind: Deployment - name: plan-mixed-changes-dev-api - - action: destroy - kind: Deployment - name: plan-mixed-changes-dev-legacy - - action: change - kind: Deployment - name: plan-mixed-changes-dev-web - fields: - - path: spec.template.spec.containers.web.image - old: "docker.io/library/nginx:1.25" - new: "docker.io/library/nginx:1.26" - - action: add - kind: Service - name: plan-mixed-changes-dev-api - - action: destroy - kind: Service - name: plan-mixed-changes-dev-legacy - -summary: - add: 2 - change: 1 - destroy: 2 diff --git a/scenarios/plan-no-changes/before.yaml b/scenarios/plan-no-changes/before.yaml deleted file mode 100644 index a84f0ef..0000000 --- a/scenarios/plan-no-changes/before.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-no-changes -components: - web: - image: nginx:latest - port: 8080 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-no-changes/deployah.yaml b/scenarios/plan-no-changes/deployah.yaml deleted file mode 100644 index a84f0ef..0000000 --- a/scenarios/plan-no-changes/deployah.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-no-changes -components: - web: - image: nginx:latest - port: 8080 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-no-changes/plan-config.yaml b/scenarios/plan-no-changes/plan-config.yaml deleted file mode 100644 index 8e7abca..0000000 --- a/scenarios/plan-no-changes/plan-config.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# Planning only. before.yaml and deployah.yaml are identical, so the -# render is the same resource set and the plan must be empty. A second -# plan against an unchanged spec is empty too. Deployment execution is -# independent of this plan result. -freshInstall: false - -changes: [] - -summary: - add: 0 - change: 0 - destroy: 0 diff --git a/scenarios/plan-resource-added/before.yaml b/scenarios/plan-resource-added/before.yaml deleted file mode 100644 index c83b20c..0000000 --- a/scenarios/plan-resource-added/before.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-resource-added -components: - web: - image: nginx:latest - port: 8080 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-resource-added/deployah.yaml b/scenarios/plan-resource-added/deployah.yaml deleted file mode 100644 index 26061ba..0000000 --- a/scenarios/plan-resource-added/deployah.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-resource-added -components: - web: - image: nginx:latest - port: 8080 - environments: [dev] - resourcePreset: small - api: - image: my-app:1.0.0 - port: 9090 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-resource-added/plan-config.yaml b/scenarios/plan-resource-added/plan-config.yaml deleted file mode 100644 index f72f452..0000000 --- a/scenarios/plan-resource-added/plan-config.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# deployah.yaml adds a new "api" component on top of before.yaml's single -# "web" component: both of its resources (Deployment and Service) show as -# additions, and the untouched web component produces no change at all. -freshInstall: false - -changes: - - action: add - kind: Deployment - name: plan-resource-added-dev-api - - action: add - kind: Service - name: plan-resource-added-dev-api - -summary: - add: 2 - change: 0 - destroy: 0 diff --git a/scenarios/plan-resource-removed/before.yaml b/scenarios/plan-resource-removed/before.yaml deleted file mode 100644 index 8664da1..0000000 --- a/scenarios/plan-resource-removed/before.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-resource-removed -components: - web: - image: nginx:latest - port: 8080 - environments: [dev] - resourcePreset: small - api: - image: my-app:1.0.0 - port: 9090 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-resource-removed/deployah.yaml b/scenarios/plan-resource-removed/deployah.yaml deleted file mode 100644 index d45124e..0000000 --- a/scenarios/plan-resource-removed/deployah.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-resource-removed -components: - web: - image: nginx:latest - port: 8080 - environments: [dev] - resourcePreset: small -environments: - dev: {} diff --git a/scenarios/plan-resource-removed/plan-config.yaml b/scenarios/plan-resource-removed/plan-config.yaml deleted file mode 100644 index 9d8dbaf..0000000 --- a/scenarios/plan-resource-removed/plan-config.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# deployah.yaml drops the "api" component that before.yaml had: both of -# its resources (Deployment and Service) show as destroys, and the -# untouched web component produces no change at all. -freshInstall: false - -changes: - - action: destroy - kind: Deployment - name: plan-resource-removed-dev-api - - action: destroy - kind: Service - name: plan-resource-removed-dev-api - -summary: - add: 0 - change: 0 - destroy: 2 diff --git a/scenarios/plan-secret-masked/current.yaml b/scenarios/plan-secret-masked/current.yaml deleted file mode 100644 index e76e42d..0000000 --- a/scenarios/plan-secret-masked/current.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# See previous.yaml: this is the raw "current" manifest, read directly -# instead of rendering deployah.yaml (which cannot produce a Secret from -# today's spec). -apiVersion: v1 -kind: Secret -metadata: - name: web-credentials - namespace: default -type: Opaque -stringData: - DB_PASSWORD: new-password -data: - API_TOKEN: bmV3LXRva2Vu diff --git a/scenarios/plan-secret-masked/plan-config.yaml b/scenarios/plan-secret-masked/plan-config.yaml deleted file mode 100644 index 6d69be9..0000000 --- a/scenarios/plan-secret-masked/plan-config.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Both stringData and data entries change; plan.ApplyMasking must flag -# both as masked (kind-based masking: any field under a Secret's data or -# stringData block), even though the diff itself always runs on the real -# values first (see internal/plan/mask.go). -freshInstall: false - -changes: - - action: change - kind: Secret - name: web-credentials - fields: - - path: stringData.DB_PASSWORD - old: "old-password" - new: "new-password" - - path: data.API_TOKEN - old: "b2xkLXRva2Vu" - new: "bmV3LXRva2Vu" - -summary: - add: 0 - change: 1 - destroy: 0 - -masked: - - stringData.DB_PASSWORD - - data.API_TOKEN diff --git a/scenarios/plan-secret-masked/previous.yaml b/scenarios/plan-secret-masked/previous.yaml deleted file mode 100644 index aa87c8e..0000000 --- a/scenarios/plan-secret-masked/previous.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Raw manifests: the deployah.yaml spec has no field that produces a -# Secret resource today (see internal/spec/types.go's Component struct), -# so this scenario diffs two raw Secret manifests directly instead of -# rendering deployah.yaml for its current side. See current.yaml. -apiVersion: v1 -kind: Secret -metadata: - name: web-credentials - namespace: default -type: Opaque -stringData: - DB_PASSWORD: old-password -data: - API_TOKEN: b2xkLXRva2Vu diff --git a/scenarios/plan-tasks-section/deployah.yaml b/scenarios/plan-tasks-section/deployah.yaml deleted file mode 100644 index 510ddfa..0000000 --- a/scenarios/plan-tasks-section/deployah.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json -apiVersion: v1-alpha.5 -project: plan-tasks-section -components: - api: - image: nginx:latest - port: 8080 - environments: [dev] - resourcePreset: small -tasks: - migrate: - from: api - "on": preDeploy - command: ["migrate", "up"] - smoke: - from: api - "on": postDeploy - command: ["curl", "-f", "http://api/health"] - backfill: - from: api - "on": manual - command: ["backfill"] -environments: - dev: {} diff --git a/scenarios/plan-tasks-section/golden.txt b/scenarios/plan-tasks-section/golden.txt deleted file mode 100644 index 37a2d44..0000000 --- a/scenarios/plan-tasks-section/golden.txt +++ /dev/null @@ -1,20 +0,0 @@ -Project: plan-tasks-section -Environment: dev -Release: plan-tasks-section-dev (fresh install) -Namespace: default - -Tasks: - preDeploy - migrate (timeout 5m) weight 0 - postDeploy - smoke (timeout 5m) weight 0 - manual (CLI only) - backfill -Note: preDeploy runs before other resources on a first install; anything it talks to must already be reachable. - -+ Deployment/plan-tasks-section-dev-api -+ Service/plan-tasks-section-dev-api - -Note: Helm hooks changed for this release (not shown above). - -Plan: 2 to add, 0 to change, 0 to destroy. diff --git a/scenarios/plan-tasks-section/plan-config.yaml b/scenarios/plan-tasks-section/plan-config.yaml deleted file mode 100644 index 1a3172a..0000000 --- a/scenarios/plan-tasks-section/plan-config.yaml +++ /dev/null @@ -1,15 +0,0 @@ -freshInstall: true -hooksChanged: true - -changes: - - action: add - kind: Deployment - name: plan-tasks-section-dev-api - - action: add - kind: Service - name: plan-tasks-section-dev-api - -summary: - add: 2 - change: 0 - destroy: 0