diff --git a/go.mod b/go.mod index 9b5aaeac..db262233 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/x/ansi v0.11.8 @@ -33,27 +32,22 @@ require ( require ( filippo.io/hpke v0.4.0 // indirect - github.com/alecthomas/chroma/v2 v2.24.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.3.1 // indirect - github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect @@ -62,21 +56,17 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.6.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.17 // indirect - github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/net v0.58.0 // indirect diff --git a/go.sum b/go.sum index 56765d67..331fbd83 100644 --- a/go.sum +++ b/go.sum @@ -8,20 +8,12 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= -github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= -github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= -github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -32,8 +24,6 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= -github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= @@ -50,8 +40,6 @@ github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9 github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/exp/teatest v0.0.0-20260803091719-3755ebad01b1 h1:wHQXg8yTV8dugUh26pT6wXpqsAMQGL0HKh4zHGMAj/Q= @@ -71,8 +59,6 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= -github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -85,10 +71,6 @@ github.com/google/renameio/v2 v2.0.2 h1:qKZs+tfn+arruZZhQ7TKC/ergJunuJicWS6gLDt/ github.com/google/renameio/v2 v2.0.2/go.mod h1:OX+G6WHHpHq3NVj7cAOleLOwJfcQ1s3uUJQCrr78SWo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jedisct1/go-minisign v0.0.0-20260527172527-a09352b57a22 h1:C68TAi+k12EKJCAmsdaERzQ22ZxVE6n+CuB3kOkhQ7c= @@ -108,11 +90,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= @@ -125,8 +104,6 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= @@ -141,8 +118,6 @@ github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJ github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= @@ -167,10 +142,6 @@ github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= -github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= -github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= diff --git a/internal/adapters/backup/hookbackup/backup.go b/internal/adapters/backup/hookbackup/backup.go index 327fb1d9..26d71859 100644 --- a/internal/adapters/backup/hookbackup/backup.go +++ b/internal/adapters/backup/hookbackup/backup.go @@ -17,6 +17,7 @@ import ( "io/fs" "os" "path/filepath" + "slices" "sort" "strconv" "strings" @@ -549,7 +550,7 @@ const ExportFileName = "export.yaml" func (e *Engine) captureExport( ctx context.Context, dir string, components []ports.Component, ) (*ports.ComponentRecord, []string, error) { - if !containsComponent(components, ports.ComponentExport) || e.export == nil { + if !slices.Contains(components, ports.ComponentExport) || e.export == nil { return nil, nil, nil } @@ -610,15 +611,6 @@ func recoveryRecipients(export domain.InstallationExport) []string { return out } -func containsComponent(components []ports.Component, want ports.Component) bool { - for _, c := range components { - if c == want { - return true - } - } - return false -} - // recordArtifacts checksums the files the hook reported. func recordArtifacts(dir string, artifacts []ports.HookArtifact) ([]ports.ComponentRecord, error) { var out []ports.ComponentRecord diff --git a/internal/adapters/backup/hookbackup/volumes.go b/internal/adapters/backup/hookbackup/volumes.go index bad6dabf..f6922893 100644 --- a/internal/adapters/backup/hookbackup/volumes.go +++ b/internal/adapters/backup/hookbackup/volumes.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "maps" "math" "path/filepath" + "slices" "sort" "strings" @@ -42,12 +44,9 @@ type volumePlan struct { // hasCold reports whether anything in the plan needs its writers stopped. func (p volumePlan) hasCold() bool { - for _, v := range p.capture { - if v.consistency == ports.ConsistencyCold { - return true - } - } - return false + return slices.ContainsFunc(p.capture, func(v plannedVolume) bool { + return v.consistency == ports.ConsistencyCold + }) } // quiesceServices is every service that must be stopped, deduplicated across @@ -67,11 +66,7 @@ func (p volumePlan) quiesceServices() []string { set[s] = true } } - out := make([]string, 0, len(set)) - for s := range set { - out = append(out, s) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(set)) return out } @@ -721,11 +716,7 @@ func (e *Engine) refuseOccupiedVolumes( return nil } - names := make([]string, 0, len(blockers)) - for service := range blockers { - names = append(names, service) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(blockers)) details := make([]string, 0, len(names)) for _, service := range names { @@ -881,15 +872,7 @@ func mountingServices(c ports.ComponentRecord, live map[string]ports.NamedVolume // componentSelected reports whether a component is in scope. An empty // selection is everything, matching RestoreOptions.Components. func componentSelected(selected []ports.Component, want ports.Component) bool { - if len(selected) == 0 { - return true - } - for _, c := range selected { - if c == want { - return true - } - } - return false + return len(selected) == 0 || slices.Contains(selected, want) } func joinServices(services []string) string { diff --git a/internal/adapters/health/health.go b/internal/adapters/health/health.go index 5e598277..25ce0e5f 100644 --- a/internal/adapters/health/health.go +++ b/internal/adapters/health/health.go @@ -12,6 +12,7 @@ import ( "fmt" "net" "net/http" + "slices" "strings" "sync" "time" @@ -488,14 +489,7 @@ func startPeriodError(specs []ports.CheckSpec, passed []bool, results []ports.He "rather than a slow boot; check service logs with `docker compose logs`") } -func allTrue(bs []bool) bool { - for _, b := range bs { - if !b { - return false - } - } - return true -} +func allTrue(bs []bool) bool { return !slices.Contains(bs, false) } // timeoutError names exactly which checks never passed, and what they last // said. "Health check failed" without that detail sends an operator to the diff --git a/internal/adapters/imagepack/imagepack.go b/internal/adapters/imagepack/imagepack.go index 5eea8c55..c561e3ba 100644 --- a/internal/adapters/imagepack/imagepack.go +++ b/internal/adapters/imagepack/imagepack.go @@ -24,13 +24,9 @@ import ( "github.com/morzecrew/morzer/internal/release" ) -// Source is where an image's bytes are read from. A registry, in production. -type Source interface { - oras.ReadOnlyTarget -} - -// OpenSource resolves an image reference to something to copy from. -type OpenSource func(ref string) (Source, error) +// OpenSource resolves an image reference to something to copy from. A +// registry, in production. +type OpenSource func(ref string) (oras.ReadOnlyTarget, error) // Packer copies images into a bundle's layout. type Packer struct { @@ -68,7 +64,7 @@ func (p *Packer) WithSource(open OpenSource) *Packer { return p } -func openRegistry(ref string) (Source, error) { +func openRegistry(ref string) (oras.ReadOnlyTarget, error) { repo, err := ocisource.OpenRepository(ref) if err != nil { return nil, err diff --git a/internal/adapters/imagepack/imagepack_test.go b/internal/adapters/imagepack/imagepack_test.go index 0f5ab027..26966b70 100644 --- a/internal/adapters/imagepack/imagepack_test.go +++ b/internal/adapters/imagepack/imagepack_test.go @@ -264,7 +264,7 @@ func imageInMemory(t *testing.T, layer string) (oras.ReadOnlyTarget, string) { } func constantSource(src oras.ReadOnlyTarget) imagepack.OpenSource { - return func(string) (imagepack.Source, error) { return src, nil } + return func(string) (oras.ReadOnlyTarget, error) { return src, nil } } func manifestWith(images map[string]domain.ImageSpec) domain.Manifest { diff --git a/internal/adapters/runtime/compose/compose.go b/internal/adapters/runtime/compose/compose.go index 467e6dd1..1e54d6f3 100644 --- a/internal/adapters/runtime/compose/compose.go +++ b/internal/adapters/runtime/compose/compose.go @@ -14,6 +14,7 @@ import ( "fmt" "io" "maps" + "slices" "sort" "strconv" "strings" @@ -197,11 +198,7 @@ func checkOptions(options map[string]string) error { } sort.Strings(unknown) - names := make([]string, 0, len(known)) - for key := range known { - names = append(names, key) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(known)) return domain.ValidationError(nil, "this release sets runtime options the compose runtime does not know: %s", @@ -272,17 +269,8 @@ func serviceNames(raw string) ([]string, error) { if err := json.Unmarshal([]byte(raw), &doc); err != nil { return nil, domain.RuntimeError(err, "cannot parse the merged compose configuration") } - names := make([]string, 0, len(doc.Services)) - for name := range doc.Services { - names = append(names, name) - } // Sorted so plans and diffs are stable between runs. - for i := 1; i < len(names); i++ { - for j := i; j > 0 && names[j] < names[j-1]; j-- { - names[j], names[j-1] = names[j-1], names[j] - } - } - return names, nil + return slices.Sorted(maps.Keys(doc.Services)), nil } // Pull fetches images. diff --git a/internal/adapters/runtime/compose/stats.go b/internal/adapters/runtime/compose/stats.go index 38af9cb1..f7b80fda 100644 --- a/internal/adapters/runtime/compose/stats.go +++ b/internal/adapters/runtime/compose/stats.go @@ -2,6 +2,7 @@ package compose import ( "context" + "maps" "slices" "strconv" "strings" @@ -47,10 +48,7 @@ func (r *Runtime) Stats(ctx context.Context, cfg ports.RuntimeConfig) ([]ports.S return nil, nil } - names := make([]string, 0, len(running)) - for name := range running { - names = append(names, name) - } + names := slices.Collect(maps.Keys(running)) slices.Sort(names) // `--no-stream` matters: the streaming form emits a first sample of diff --git a/internal/adapters/runtime/compose/volumes.go b/internal/adapters/runtime/compose/volumes.go index 1adee2aa..72f51003 100644 --- a/internal/adapters/runtime/compose/volumes.go +++ b/internal/adapters/runtime/compose/volumes.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "fmt" + "maps" "math" "os" "regexp" + "slices" "sort" "strconv" "strings" @@ -188,7 +190,7 @@ func parseStorage(raw string) (ports.ProjectStorage, error) { Name: name, Actual: actual, External: declared.External, - Services: sortedKeys(users), + Services: slices.Sorted(maps.Keys(users)), }) } // A declared volume nothing mounts is still the project's storage, and @@ -208,7 +210,7 @@ func parseStorage(raw string) (ports.ProjectStorage, error) { for source, users := range bindUsers { out.Binds = append(out.Binds, ports.BindMount{ - Source: source, Services: sortedKeys(users), + Source: source, Services: slices.Sorted(maps.Keys(users)), }) } @@ -251,15 +253,6 @@ func addUser(index map[string]map[string]bool, key, service string) { index[key][service] = true } -func sortedKeys(set map[string]bool) []string { - out := make([]string, 0, len(set)) - for k := range set { - out = append(out, k) - } - sort.Strings(out) - return out -} - // CaptureVolume writes a volume's contents to destPath as an uncompressed tar. // // The tar arrives on the helper's stdout and is written here rather than into a diff --git a/internal/adapters/scheme/scheme.go b/internal/adapters/scheme/scheme.go new file mode 100644 index 00000000..009826ee --- /dev/null +++ b/internal/adapters/scheme/scheme.go @@ -0,0 +1,125 @@ +// Package scheme indexes adapters by the reference scheme each one declares. +// +// Two ports select an adapter the same way: a release source by a reference's +// scheme, a backup target by a URL's. Both were written out in full, and the +// copies had already drifted -- the target registry closes over the argument +// list because hashing an interface value panics for a type that is not +// comparable, while the source registry still deduplicated its close loop +// through exactly such a set. +// +// One copy, so "a nil adapter is refused", "two adapters may not claim one +// scheme" and "each adapter closes once however many schemes it answers for" +// are facts about both rather than about whichever was edited last. +package scheme + +import ( + "errors" + "io" + "maps" + "reflect" + "slices" + + "github.com/morzecrew/morzer/internal/domain" +) + +// Adapter is anything that answers for a set of schemes. +type Adapter interface { + Schemes() []string +} + +// Index dispatches to the adapter registered for a scheme. +type Index[T Adapter] struct { + byScheme map[string]T + + // registered is the argument list, in order, one entry per adapter + // however many schemes it claims. Close walks it rather than + // deduplicating the scheme map through a set keyed by the interface + // value: hashing an interface whose dynamic type is not comparable + // panics, and shutdown is the worst place to find that out. + registered []T +} + +// NewIndex indexes each adapter under every scheme it declares. kind names what +// is being registered -- "release source", "backup target" -- and appears in +// the refusals, pluralised by an "s". +// +// Two adapters claiming one scheme is a wiring mistake with no sensible +// resolution: last-wins would make behaviour depend on argument order, and +// first-wins would silently ignore an adapter someone deliberately added. An +// empty index is the same kind of mistake seen later -- a build in which every +// operation fails when it runs rather than when it is assembled. +// +// A nil adapter is refused rather than skipped, for the same reason: dropping +// it quietly leaves a build whose sftp:// URLs fail at push time as though the +// transport had never been compiled in. The check cannot be `a == nil` alone -- +// a nil *sftp.Target satisfies the interface, registers happily, and panics at +// shutdown when Close dereferences it. +// +// These are errors rather than panics because the caller assembling the graph +// already returns one, so they surface at startup with the rest. +func NewIndex[T Adapter](kind string, adapters ...T) (*Index[T], error) { + idx := &Index[T]{byScheme: make(map[string]T, len(adapters))} + + for _, a := range adapters { + if isNil(a) { + return nil, domain.Internal(nil, "a nil %s was registered", kind) + } + schemes := a.Schemes() + if len(schemes) == 0 { + return nil, domain.Internal(nil, "a %s declares no schemes", kind) + } + for _, s := range schemes { + if _, taken := idx.byScheme[s]; taken { + return nil, domain.Internal(nil, + "two %ss both claim the %q scheme", kind, s) + } + idx.byScheme[s] = a + } + idx.registered = append(idx.registered, a) + } + + if len(idx.byScheme) == 0 { + return nil, domain.Internal(nil, "no %ss were registered", kind) + } + return idx, nil +} + +// isNil reports an adapter that carries no value. An interface holding a typed +// nil pointer is not == nil, so the plain comparison lets one through. +func isNil[T Adapter](a T) bool { + switch v := reflect.ValueOf(a); v.Kind() { + case reflect.Invalid: + // An untyped nil: the interface holds no type at all. + return true + case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan: + return v.IsNil() + default: + return false + } +} + +// Schemes lists what this build has, sorted. +func (i *Index[T]) Schemes() []string { return slices.Sorted(maps.Keys(i.byScheme)) } + +// Lookup returns the adapter registered for a scheme. +func (i *Index[T]) Lookup(scheme string) (T, bool) { + a, ok := i.byScheme[scheme] + return a, ok +} + +// Close releases every adapter that holds anything -- an SSH connection or a +// download directory above all. +// +// Every adapter is closed even after one fails: one that cannot tidy up must +// not leave the next one's socket open or its bundle on disk. +func (i *Index[T]) Close() error { + var errs []error + for _, a := range i.registered { + if closer, ok := any(a).(io.Closer); ok { + if err := closer.Close(); err != nil { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} diff --git a/internal/adapters/scheme/scheme_test.go b/internal/adapters/scheme/scheme_test.go new file mode 100644 index 00000000..c6e90f64 --- /dev/null +++ b/internal/adapters/scheme/scheme_test.go @@ -0,0 +1,120 @@ +package scheme + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ptrAdapter is the ordinary shape: a pointer receiver, comparable. +type ptrAdapter struct { + schemes []string + closes *int + closeErr error +} + +func (a *ptrAdapter) Schemes() []string { return a.schemes } + +func (a *ptrAdapter) Close() error { + if a.closes != nil { + *a.closes++ + } + return a.closeErr +} + +// valueAdapter is a target implemented on a value with a slice field. Nothing +// says an adapter has to be a pointer, and hashing this one as a map key +// panics -- which is why Close walks the argument list instead of a set. +type valueAdapter struct { + schemes []string + closes *int +} + +func (a valueAdapter) Schemes() []string { return a.schemes } +func (a valueAdapter) Close() error { *a.closes++; return nil } + +func TestNewIndexRefusesANilAdapter(t *testing.T) { + t.Run("untyped", func(t *testing.T) { + _, err := NewIndex[Adapter]("release source", &ptrAdapter{schemes: []string{"file"}}, nil) + require.Error(t, err, "a nil in the list is a wiring mistake, not an adapter to skip") + assert.Contains(t, err.Error(), "nil release source") + }) + + t.Run("typed nil pointer", func(t *testing.T) { + // The spelling t == nil does not catch: it satisfies the + // interface, registers, and takes the process down at shutdown. + var missing *ptrAdapter + + _, err := NewIndex[Adapter]("backup target", &ptrAdapter{schemes: []string{"file"}}, missing) + require.Error(t, err) + assert.Contains(t, err.Error(), "nil backup target") + }) +} + +func TestNewIndexRefusesDuplicateAndEmptyWiring(t *testing.T) { + _, err := NewIndex("release source", + &ptrAdapter{schemes: []string{"file"}}, &ptrAdapter{schemes: []string{"file"}}) + require.Error(t, err) + assert.Contains(t, err.Error(), `two release sources both claim the "file" scheme`) + + _, err = NewIndex("backup target", &ptrAdapter{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "declares no schemes") + + _, err = NewIndex[*ptrAdapter]("backup target") + require.Error(t, err) + assert.Contains(t, err.Error(), "no backup targets were registered") +} + +func TestLookupAndSchemes(t *testing.T) { + one := &ptrAdapter{schemes: []string{"ssh", "file"}} + two := &ptrAdapter{schemes: []string{"s3"}} + + index, err := NewIndex("backup target", one, two) + require.NoError(t, err) + + assert.Equal(t, []string{"file", "s3", "ssh"}, index.Schemes(), "sorted, so refusals read the same every run") + + got, ok := index.Lookup("ssh") + require.True(t, ok) + assert.Same(t, one, got) + + _, ok = index.Lookup("ftp") + assert.False(t, ok) +} + +// TestCloseVisitsEachAdapterOnce. Two schemes on one adapter must not close it +// twice, and an adapter whose type is not comparable must not panic the walk. +func TestCloseVisitsEachAdapterOnce(t *testing.T) { + var ptrCloses, valueCloses int + index, err := NewIndex[Adapter]( + "backup target", + &ptrAdapter{schemes: []string{"ssh", "file"}, closes: &ptrCloses}, + valueAdapter{schemes: []string{"one", "two"}, closes: &valueCloses}, + ) + require.NoError(t, err) + + require.NotPanics(t, func() { require.NoError(t, index.Close()) }) + assert.Equal(t, 1, ptrCloses) + assert.Equal(t, 1, valueCloses) +} + +// TestCloseKeepsGoingAfterAFailure. An adapter that cannot tidy up must not +// leave the next one's socket open. +func TestCloseKeepsGoingAfterAFailure(t *testing.T) { + boom := errors.New("cannot close") + var closes int + + index, err := NewIndex[Adapter]( + "release source", + &ptrAdapter{schemes: []string{"file"}, closeErr: boom}, + &ptrAdapter{schemes: []string{"https"}, closes: &closes}, + ) + require.NoError(t, err) + + err = index.Close() + require.ErrorIs(t, err, boom) + assert.Equal(t, 1, closes, "the second source was closed despite the first failing") +} diff --git a/internal/adapters/sign/minisign/keyfile.go b/internal/adapters/sign/minisign/keyfile.go index 60d7b6b9..b51e7335 100644 --- a/internal/adapters/sign/minisign/keyfile.go +++ b/internal/adapters/sign/minisign/keyfile.go @@ -22,6 +22,7 @@ import ( "encoding/binary" "encoding/hex" "fmt" + "slices" "strings" gominisign "github.com/jedisct1/go-minisign" @@ -178,10 +179,10 @@ func keyIDFor(sk gominisign.PrivateKey) string { return strings.ToUpper(hex.EncodeToString(reverse(id[:]))) } +// reverse copies rather than reversing in place: both callers pass a slice of +// a key's own id array, which the caller still holds. func reverse(b []byte) []byte { - out := make([]byte, len(b)) - for i := range b { - out[len(b)-1-i] = b[i] - } + out := slices.Clone(b) + slices.Reverse(out) return out } diff --git a/internal/adapters/source/https/https.go b/internal/adapters/source/https/https.go index 77815356..a076c496 100644 --- a/internal/adapters/source/https/https.go +++ b/internal/adapters/source/https/https.go @@ -34,7 +34,6 @@ import ( "github.com/morzecrew/morzer/internal/adapters/source" "github.com/morzecrew/morzer/internal/adapters/source/local" "github.com/morzecrew/morzer/internal/domain" - "github.com/morzecrew/morzer/internal/infra/atomicfs" "github.com/morzecrew/morzer/internal/ports" ) @@ -73,17 +72,9 @@ func WithTransport(rt http.RoundTripper) Option { // WithMaxBody bounds a response body. func WithMaxBody(n int64) Option { return func(s *Source) { s.maxBody = n } } -// WithAttempts sets how many times a retryable failure is retried. -func WithAttempts(n int) Option { return func(s *Source) { s.attempts = n } } - // WithBackoff sets the base delay between attempts. func WithBackoff(d time.Duration) Option { return func(s *Source) { s.backoff = d } } -// WithLimits overrides the extraction limits applied after download. -func WithLimits(l atomicfs.ExtractLimits) Option { - return func(s *Source) { s.local = s.local.WithLimits(l) } -} - func New(opts ...Option) *Source { s := &Source{ client: &http.Client{ diff --git a/internal/adapters/source/local/local.go b/internal/adapters/source/local/local.go index aed46d47..6321a266 100644 --- a/internal/adapters/source/local/local.go +++ b/internal/adapters/source/local/local.go @@ -40,12 +40,6 @@ func New() *Source { return &Source{limits: limits} } -// WithLimits overrides the extraction limits. -func (s *Source) WithLimits(l atomicfs.ExtractLimits) *Source { - s.limits = l - return s -} - var _ ports.ReleaseSource = (*Source)(nil) func (s *Source) Schemes() []string { return []string{Scheme} } diff --git a/internal/adapters/source/oci/oci.go b/internal/adapters/source/oci/oci.go index 17e9f753..7c671c58 100644 --- a/internal/adapters/source/oci/oci.go +++ b/internal/adapters/source/oci/oci.go @@ -41,7 +41,6 @@ import ( "github.com/morzecrew/morzer/internal/adapters/source" "github.com/morzecrew/morzer/internal/adapters/source/local" "github.com/morzecrew/morzer/internal/domain" - "github.com/morzecrew/morzer/internal/infra/atomicfs" "github.com/morzecrew/morzer/internal/ports" ) @@ -93,14 +92,6 @@ type Registry interface { type Option func(*Source) -// WithLimits overrides the extraction limits applied after the pull. -func WithLimits(l atomicfs.ExtractLimits) Option { - return func(s *Source) { s.local = s.local.WithLimits(l) } -} - -// WithMaxBlobSize bounds a layer. -func WithMaxBlobSize(n int64) Option { return func(s *Source) { s.maxBlob = n } } - // WithRepositoryFactory replaces how repositories are opened. See Registry. func WithRepositoryFactory(f func(reference string) (Registry, error)) Option { return func(s *Source) { s.newRepository = f } diff --git a/internal/adapters/source/registry.go b/internal/adapters/source/registry.go index 141378e8..e3fbbdd2 100644 --- a/internal/adapters/source/registry.go +++ b/internal/adapters/source/registry.go @@ -9,63 +9,34 @@ package source import ( "context" - "errors" "io" - "sort" "strings" + "github.com/morzecrew/morzer/internal/adapters/scheme" "github.com/morzecrew/morzer/internal/domain" "github.com/morzecrew/morzer/internal/ports" ) // Registry dispatches to the source registered for a reference's scheme. +// +// Schemes and Close come from the embedded index, which also holds the wiring +// refusals -- see internal/adapters/scheme. type Registry struct { - byScheme map[string]ports.ReleaseSource + *scheme.Index[ports.ReleaseSource] } -var _ ports.ReleaseSource = (*Registry)(nil) +var ( + _ ports.ReleaseSource = (*Registry)(nil) + _ io.Closer = (*Registry)(nil) +) // NewRegistry indexes each source under every scheme it declares. -// -// Two sources claiming one scheme is a wiring mistake with no sensible -// resolution -- last-wins would make behaviour depend on argument order, and -// first-wins would silently ignore an adapter someone deliberately added. It is -// an error rather than a panic because the caller assembling the graph already -// returns one, so it surfaces at startup with the rest. func NewRegistry(sources ...ports.ReleaseSource) (*Registry, error) { - r := &Registry{byScheme: make(map[string]ports.ReleaseSource, len(sources))} - - for _, s := range sources { - if s == nil { - continue - } - schemes := s.Schemes() - if len(schemes) == 0 { - return nil, domain.Internal(nil, "a release source declares no schemes") - } - for _, scheme := range schemes { - if _, taken := r.byScheme[scheme]; taken { - return nil, domain.Internal(nil, - "two release sources both claim the %q scheme", scheme) - } - r.byScheme[scheme] = s - } - } - - if len(r.byScheme) == 0 { - return nil, domain.Internal(nil, "no release sources were registered") - } - return r, nil -} - -// Schemes lists what this build can fetch, sorted. -func (r *Registry) Schemes() []string { - out := make([]string, 0, len(r.byScheme)) - for scheme := range r.byScheme { - out = append(out, scheme) + index, err := scheme.NewIndex("release source", sources...) + if err != nil { + return nil, err } - sort.Strings(out) - return out + return &Registry{Index: index}, nil } // For selects the source for a reference. @@ -75,7 +46,7 @@ func (r *Registry) Schemes() []string { // operator asking for something reasonable, and the answer should tell them // what to do instead rather than only that they are wrong. func (r *Registry) For(ref ports.Ref) (ports.ReleaseSource, error) { - if s, ok := r.byScheme[ref.Scheme]; ok { + if s, ok := r.Lookup(ref.Scheme); ok { return s, nil } return nil, domain.Usage("no release source is configured for %q references", ref.Scheme). @@ -129,30 +100,3 @@ func (r *Registry) List(ctx context.Context, ref ports.Ref) ([]domain.Version, e } return s.List(ctx, ref) } - -var _ io.Closer = (*Registry)(nil) - -// Close releases every source that holds anything, so a caller can clean up -// without knowing which transports it happens to have registered. -// -// Every source is closed even after one fails: a transport that cannot tidy up -// must not leave the next one's download on disk. -func (r *Registry) Close() error { - var errs []error - seen := make(map[ports.ReleaseSource]bool, len(r.byScheme)) - - for _, s := range r.byScheme { - // One source may answer for several schemes. - if seen[s] { - continue - } - seen[s] = true - - if closer, ok := s.(io.Closer); ok { - if err := closer.Close(); err != nil { - errs = append(errs, err) - } - } - } - return errors.Join(errs...) -} diff --git a/internal/adapters/supervisor/systemd/systemd_test.go b/internal/adapters/supervisor/systemd/systemd_test.go index 214f8014..3fe9677c 100644 --- a/internal/adapters/supervisor/systemd/systemd_test.go +++ b/internal/adapters/supervisor/systemd/systemd_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "slices" "strings" "testing" @@ -232,12 +233,9 @@ func enablesSince(runner *exec.Scripted, from int) int { } func ranIn(calls []exec.Command, match string) bool { - for _, c := range calls { - if strings.Contains(strings.Join(c.Argv, " "), match) { - return true - } - } - return false + return slices.ContainsFunc(calls, func(c exec.Command) bool { + return strings.Contains(strings.Join(c.Argv, " "), match) + }) } // TestInstallUnitsRefusesANameThatIsAPath is the traversal guard. A unit name diff --git a/internal/adapters/target/registry.go b/internal/adapters/target/registry.go index 02477fa8..1f7cbe07 100644 --- a/internal/adapters/target/registry.go +++ b/internal/adapters/target/registry.go @@ -10,91 +10,34 @@ package target import ( "context" - "errors" "io" - "reflect" - "sort" "strings" + "github.com/morzecrew/morzer/internal/adapters/scheme" "github.com/morzecrew/morzer/internal/domain" "github.com/morzecrew/morzer/internal/ports" ) // Registry dispatches to the target registered for a URL's scheme. +// +// Schemes and Close come from the embedded index, which also holds the wiring +// refusals -- see internal/adapters/scheme. type Registry struct { - byScheme map[string]ports.BackupTarget - - // registered is the argument list, in order, one entry per target - // however many schemes it claims. Close walks it rather than - // deduplicating the scheme map through a set keyed by the interface - // value: hashing an interface whose dynamic type is not comparable - // panics, and shutdown is the worst place to find that out. - registered []ports.BackupTarget + *scheme.Index[ports.BackupTarget] } -var _ ports.BackupTarget = (*Registry)(nil) +var ( + _ ports.BackupTarget = (*Registry)(nil) + _ io.Closer = (*Registry)(nil) +) // NewRegistry indexes each target under every scheme it declares. -// -// Two targets claiming one scheme is a wiring mistake with no sensible -// resolution, and an empty registry means a build in which every configured -// target would fail at push time -- late, during the nightly backup, rather -// than at startup with the rest. -// -// A nil target is the same kind of mistake, and is refused rather than skipped -// for the same reason: dropping it quietly leaves a build whose sftp:// URLs -// fail at push time as though the transport had never been compiled in. The -// check cannot be t == nil alone -- a nil *sftp.Target satisfies the interface, -// registers happily, and panics at shutdown when Close dereferences it. func NewRegistry(targets ...ports.BackupTarget) (*Registry, error) { - r := &Registry{byScheme: make(map[string]ports.BackupTarget, len(targets))} - - for _, t := range targets { - if isNil(t) { - return nil, domain.Internal(nil, "a nil backup target was registered") - } - schemes := t.Schemes() - if len(schemes) == 0 { - return nil, domain.Internal(nil, "a backup target declares no schemes") - } - for _, scheme := range schemes { - if _, taken := r.byScheme[scheme]; taken { - return nil, domain.Internal(nil, - "two backup targets both claim the %q scheme", scheme) - } - r.byScheme[scheme] = t - } - r.registered = append(r.registered, t) - } - - if len(r.byScheme) == 0 { - return nil, domain.Internal(nil, "no backup targets were registered") - } - return r, nil -} - -// isNil reports a target that carries no value. An interface holding a typed -// nil pointer is not == nil, so the plain comparison lets one through. -func isNil(t ports.BackupTarget) bool { - if t == nil { - return true - } - switch v := reflect.ValueOf(t); v.Kind() { - case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan: - return v.IsNil() - default: - return false - } -} - -// Schemes lists what this build can push to, sorted. -func (r *Registry) Schemes() []string { - out := make([]string, 0, len(r.byScheme)) - for scheme := range r.byScheme { - out = append(out, scheme) + index, err := scheme.NewIndex("backup target", targets...) + if err != nil { + return nil, err } - sort.Strings(out) - return out + return &Registry{Index: index}, nil } // For selects the target for a reference. @@ -104,7 +47,7 @@ func (r *Registry) Schemes() []string { // answer should tell them what to do instead rather than only that they are // wrong. func (r *Registry) For(ref ports.TargetRef) (ports.BackupTarget, error) { - if t, ok := r.byScheme[ref.Scheme]; ok { + if t, ok := r.Lookup(ref.Scheme); ok { return t, nil } return nil, domain.Usage("no backup target is configured for %q URLs", ref.Scheme). @@ -207,24 +150,3 @@ func (r *Registry) objectStore(ref ports.TargetRef) (ports.ObjectStore, error) { } return store, nil } - -var _ io.Closer = (*Registry)(nil) - -// Close releases anything a target holds -- an SSH connection above all. -// -// Every target is closed even after one fails, so a transport that cannot tidy -// up does not leave the next one's socket open. Each target appears once in -// registered however many schemes it answers for, so the loop needs no -// deduplication of its own. -func (r *Registry) Close() error { - var errs []error - - for _, t := range r.registered { - if closer, ok := t.(io.Closer); ok { - if err := closer.Close(); err != nil { - errs = append(errs, err) - } - } - } - return errors.Join(errs...) -} diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 585360a7..c0ecec0c 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -5,7 +5,8 @@ import ( "errors" "fmt" "io" - "sort" + "maps" + "slices" "strings" "time" @@ -321,7 +322,7 @@ func newUpdateCommand(app *App) *cobra.Command { return nil } app.finish(ops.Result{Summary: res.Summary()}) - if notes := ui.RenderNotes(app.Mode, res.Notes); notes != "" { + if notes := strings.TrimSpace(res.Notes); notes != "" { fmt.Fprintf(app.Stream.Err, "\n%s\n", notes) } return nil @@ -488,7 +489,11 @@ func printStagedNotes(ctx context.Context, app *App) { if err != nil { return } - if notes := ui.RenderNotes(app.Mode, release.Notes(rel)); notes != "" { + // The vendor's Markdown, as written. Nothing here reflows it: a wrap + // inserted into a fenced block breaks the command an operator is about + // to copy, and knowing which lines may be wrapped means parsing + // Markdown -- which is a renderer, which is what this stopped linking. + if notes := strings.TrimSpace(release.Notes(rel)); notes != "" { fmt.Fprintf(app.Stream.Err, "\n%s\n", notes) } } @@ -986,11 +991,7 @@ func parseComponents(names []string) ([]ports.Component, error) { for _, name := range names { c, ok := valid[strings.TrimSpace(strings.ToLower(name))] if !ok { - known := make([]string, 0, len(valid)) - for k := range valid { - known = append(known, k) - } - sort.Strings(known) + known := slices.Sorted(maps.Keys(valid)) return nil, domain.Usage("unknown backup component %q", name). WithHint("valid components: %s", strings.Join(known, ", ")) } diff --git a/internal/cli/completion.go b/internal/cli/completion.go index 7332cf92..1f6c4959 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -3,8 +3,10 @@ package cli import ( "bytes" "fmt" + "maps" "os" "path/filepath" + "slices" "strings" "github.com/spf13/cobra" @@ -140,10 +142,7 @@ func unknownShell(shell string) error { // completionShells are the shells this command can place a file for, sorted. func completionShells() []string { - out := make([]string, 0, len(completionTargets)) - for name := range completionTargets { - out = append(out, name) - } + out := slices.Collect(maps.Keys(completionTargets)) sortStrings(out) return out } diff --git a/internal/cli/groups_internal_test.go b/internal/cli/groups_internal_test.go index 9eda4236..2a301686 100644 --- a/internal/cli/groups_internal_test.go +++ b/internal/cli/groups_internal_test.go @@ -104,8 +104,8 @@ func TestTheFirstSectionIsInTheOrderYouWouldRunIt(t *testing.T) { // The listing is ` `, and a Short long enough to wrap // turns the section into a paragraph -- which is the readability this whole // grouping exists for, undone one command at a time. Eighty because that is the -// width the rest of this project treats as the terminal's floor (`RenderNotes` -// wraps there, and it says why). +// width a terminal is assumed to have when nothing says otherwise, and help is +// read on whatever the operator happens to be sitting at. func TestHelpLinesFitEightyColumns(t *testing.T) { const limit = 80 diff --git a/internal/cli/init_wizard.go b/internal/cli/init_wizard.go index 0d8d24d9..0e30cba4 100644 --- a/internal/cli/init_wizard.go +++ b/internal/cli/init_wizard.go @@ -4,8 +4,9 @@ import ( "context" "errors" "fmt" + "maps" "os" - "sort" + "slices" "strings" "github.com/charmbracelet/huh" @@ -335,7 +336,7 @@ func EquivalentCommand(opts ops.InitOptions) string { // Sorted, so the printed line is the same twice for the same options: // this goes into a provisioning script, where a command that reorders // itself between runs is a diff nobody can read. - for _, name := range sortedKeys(opts.Parameters) { + for _, name := range slices.Sorted(maps.Keys(opts.Parameters)) { add("set", name+"="+opts.Parameters[name]) } add("recovery-recipient", opts.RecoveryRecipient) @@ -362,15 +363,6 @@ func EquivalentCommand(opts ops.InitOptions) string { return strings.Join(args, " \\\n ") } -func sortedKeys(m map[string]string) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - // shellQuote quotes a value only when it needs it, so the common case stays // readable enough to copy. func shellQuote(s string) string { diff --git a/internal/cli/release_new.go b/internal/cli/release_new.go index 1cd6ce9f..e4afb59a 100644 --- a/internal/cli/release_new.go +++ b/internal/cli/release_new.go @@ -2,8 +2,10 @@ package cli import ( "fmt" + "maps" "os" "path/filepath" + "slices" "sort" "strings" @@ -143,10 +145,7 @@ func rollbackScaffold(written []string, cause error) error { } func sortedScaffoldPaths(files map[string]string) []string { - out := make([]string, 0, len(files)) - for rel := range files { - out = append(out, rel) - } + out := slices.Collect(maps.Keys(files)) // Sorted so the existence check and the write visit the same order, // and so a failure reports the same file twice in a row rather than a // different one each run. diff --git a/internal/cli/secret_edit.go b/internal/cli/secret_edit.go index 0cb423b8..0c82a68d 100644 --- a/internal/cli/secret_edit.go +++ b/internal/cli/secret_edit.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "maps" "os" osexec "os/exec" "path/filepath" + "slices" "sort" "strings" @@ -182,11 +184,7 @@ func (a *App) newEditSession() (dir, path string, err error) { func writeEditFile(path string, values map[string]string) error { // Sorted, so a diff between two sessions is about what changed rather // than about map iteration order. - names := make([]string, 0, len(values)) - for name := range values { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(values)) ordered := yaml.MapSlice{} for _, name := range names { diff --git a/internal/domain/attest_verify.go b/internal/domain/attest_verify.go index 362cdc83..2c9ef3db 100644 --- a/internal/domain/attest_verify.go +++ b/internal/domain/attest_verify.go @@ -2,6 +2,8 @@ package domain import ( "fmt" + "maps" + "slices" "sort" "strings" ) @@ -303,11 +305,7 @@ func CompareToLive(stmt Statement, live []LiveImage) []LiveMismatch { } } - digests := make([]string, 0, len(attested)) - for d := range attested { - digests = append(digests, d) - } - sort.Strings(digests) + digests := slices.Sorted(maps.Keys(attested)) for _, d := range digests { if !seen[d] { img := attested[d] diff --git a/internal/domain/attestation.go b/internal/domain/attestation.go index 0055239d..ce751de7 100644 --- a/internal/domain/attestation.go +++ b/internal/domain/attestation.go @@ -6,7 +6,8 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "sort" + "maps" + "slices" "strings" "unicode" "unicode/utf8" @@ -359,11 +360,7 @@ func CanonicalConfig(rendered map[string][]byte) []byte { return nil } - targets := make([]string, 0, len(rendered)) - for target := range rendered { - targets = append(targets, target) - } - sort.Strings(targets) + targets := slices.Sorted(maps.Keys(rendered)) var buf bytes.Buffer for _, target := range targets { @@ -399,10 +396,6 @@ func parameterNames(params map[string]string) []string { if len(params) == 0 { return nil } - names := make([]string, 0, len(params)) - for name := range params { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(params)) return names } diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 8175bba3..488dccbe 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -83,21 +83,8 @@ var ( // possible. A sentinel rather than an empty return value so a caller has // to decide which of the two it means: `status` reports it, and a signer // mints. - // - // Distinct from a key that disagrees with recorded state, which is - // ErrSigningKeyMismatch and is a machine to stop. ErrNoSigningKey = errors.New("installation has no signing key") - // ErrSigningKeyMismatch marks a signing key file whose public half is - // not the one installation state records. - // - // This is the refusal RFC 0028 §5.4 asks for, and it is narrower than - // "there is no key": such a machine would sign with one key while - // telling everybody -- through `status`, the export, an attestation -- - // that it signs with another, and its artifacts are attributable to - // nobody. Absence is ordinary; disagreement is not. - ErrSigningKeyMismatch = errors.New("signing key does not match recorded public key") - // ErrTemplateSyntax marks a manifest template that does not parse, as // opposed to one that parses and refers to something absent. // @@ -284,14 +271,6 @@ func NoSigningKey(cause error, format string, args ...any) *Error { return newf(CodeSecrets, CategoryUser, ErrNoSigningKey, cause, format, args...) } -// SigningKeyMismatch reports a key file that disagrees with recorded state. -// -// CategorySystem rather than User: nothing the operator typed produced this, -// and the machine is in a state where its own artifacts cannot be attributed. -func SigningKeyMismatch(cause error, format string, args ...any) *Error { - return newf(CodeSecrets, CategorySystem, ErrSigningKeyMismatch, cause, format, args...) -} - func RuntimeError(cause error, format string, args ...any) *Error { return newf(CodeRuntime, CategorySystem, ErrRuntime, cause, format, args...) } diff --git a/internal/domain/image.go b/internal/domain/image.go index d6a95dfe..acdf994f 100644 --- a/internal/domain/image.go +++ b/internal/domain/image.go @@ -2,7 +2,9 @@ package domain import ( "encoding/json" + "maps" "regexp" + "slices" "sort" "strconv" "strings" @@ -335,11 +337,7 @@ func (m *Manifest) imageRefs(keep func(ImageSpec) bool) []string { } func sortedImageNames(images map[string]ImageSpec) []string { - names := make([]string, 0, len(images)) - for name := range images { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(images)) return names } diff --git a/internal/domain/installation.go b/internal/domain/installation.go index 5c251078..0de0ceb6 100644 --- a/internal/domain/installation.go +++ b/internal/domain/installation.go @@ -2,6 +2,7 @@ package domain import ( "fmt" + "slices" "strings" "time" "unicode" @@ -197,14 +198,7 @@ func ParseMode(s string) (Mode, error) { } // Valid reports whether a non-empty mode is one this manager knows. -func (m Mode) Valid() bool { - for _, known := range Modes { - if m == known { - return true - } - } - return false -} +func (m Mode) Valid() bool { return slices.Contains(Modes, m) } func joinModes() string { out := make([]string, len(Modes)) diff --git a/internal/domain/manifest.go b/internal/domain/manifest.go index 7458ef1a..e37ec350 100644 --- a/internal/domain/manifest.go +++ b/internal/domain/manifest.go @@ -4,9 +4,11 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net/url" "path" "regexp" + "slices" "sort" "strconv" "strings" @@ -232,11 +234,7 @@ func (m Manifest) ProfileNames() []string { seen[name] = true } } - out := make([]string, 0, len(seen)) - for name := range seen { - out = append(out, name) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(seen)) return out } @@ -296,11 +294,7 @@ func (r RuntimeSpec) ComposeFiles(profile string) ([]string, error) { } extra, ok := r.Profiles[profile] if !ok { - known := make([]string, 0, len(r.Profiles)) - for name := range r.Profiles { - known = append(known, name) - } - sort.Strings(known) + known := slices.Sorted(maps.Keys(r.Profiles)) return nil, ValidationError(nil, "unknown deployment profile %q", profile). WithHint("profiles declared by this release: %s", strings.Join(known, ", ")) } @@ -308,7 +302,7 @@ func (r RuntimeSpec) ComposeFiles(profile string) ([]string, error) { // Compose would merge it with itself and the operator would see // confusing duplicate-key diagnostics. for _, f := range extra { - if !containsString(files, f) { + if !slices.Contains(files, f) { files = append(files, f) } } @@ -360,16 +354,12 @@ func (d RuntimeDecl) FilesFor(profile string) ([]string, error) { } extra, ok := d.Profiles[profile] if !ok { - known := make([]string, 0, len(d.Profiles)) - for name := range d.Profiles { - known = append(known, name) - } - sort.Strings(known) + known := slices.Sorted(maps.Keys(d.Profiles)) return nil, ValidationError(nil, "unknown deployment profile %q", profile). WithHint("profiles declared by this release: %s", strings.Join(known, ", ")) } for _, f := range extra { - if !containsString(files, f) { + if !slices.Contains(files, f) { files = append(files, f) } } @@ -419,11 +409,7 @@ type Runtimes map[string]RuntimeDecl // Names returns the declared runtimes, sorted, so every message that lists // them lists them in the same order. func (r Runtimes) Names() []string { - names := make([]string, 0, len(r)) - for name := range r { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(r)) return names } @@ -1267,14 +1253,7 @@ func (m *Manifest) ImageRefs() []string { return refs } -func isSupportedAPIVersion(v APIVersion) bool { - for _, s := range SupportedAPIVersions { - if s == v { - return true - } - } - return false -} +func isSupportedAPIVersion(v APIVersion) bool { return slices.Contains(SupportedAPIVersions, v) } func joinAPIVersions(vs []APIVersion) string { out := make([]string, len(vs)) @@ -1292,15 +1271,6 @@ func joinConsistencies(vs []VolumeConsistency) string { return strings.Join(out, ", ") } -func containsString(haystack []string, needle string) bool { - for _, h := range haystack { - if h == needle { - return true - } - } - return false -} - // validationErrors accumulates field-level complaints so Validate can report // every problem in one pass. type validationErrors struct { diff --git a/internal/domain/parameter.go b/internal/domain/parameter.go index cf76113a..383b515b 100644 --- a/internal/domain/parameter.go +++ b/internal/domain/parameter.go @@ -2,7 +2,9 @@ package domain import ( "fmt" + "maps" "regexp" + "slices" "sort" "strconv" "strings" @@ -353,14 +355,7 @@ func ParseAssignments(args []string) (map[string]string, error) { return out, nil } -func isParameterType(t ParameterType) bool { - for _, known := range ParameterTypes { - if t == known { - return true - } - } - return false -} +func isParameterType(t ParameterType) bool { return slices.Contains(ParameterTypes, t) } func joinParameterTypes() string { names := make([]string, len(ParameterTypes)) @@ -371,21 +366,13 @@ func joinParameterTypes() string { } func declaredNames(m map[string]ParameterSpec) []string { - out := make([]string, 0, len(m)) - for name := range m { - out = append(out, name) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(m)) return out } func sortedParameterNames(m map[string]ParameterSpec) []string { return declaredNames(m) } func sortedStringKeys(m map[string]string) []string { - out := make([]string, 0, len(m)) - for name := range m { - out = append(out, name) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(m)) return out } diff --git a/internal/domain/secret.go b/internal/domain/secret.go index a18f6472..da620532 100644 --- a/internal/domain/secret.go +++ b/internal/domain/secret.go @@ -2,6 +2,8 @@ package domain import ( "log/slog" + "maps" + "slices" "sort" "strings" ) @@ -67,11 +69,7 @@ func (s SecretSet) Has(name string) bool { // Names returns secret names in sorted order. Names are not sensitive -- // `secret list` shows them -- but values never accompany them. func (s SecretSet) Names() []string { - out := make([]string, 0, len(s.values)) - for n := range s.values { - out = append(out, n) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(s.values)) return out } diff --git a/internal/domain/support.go b/internal/domain/support.go index d2cd1bb0..b6b6e2bc 100644 --- a/internal/domain/support.go +++ b/internal/domain/support.go @@ -1,6 +1,9 @@ package domain -import "sort" +import ( + "maps" + "slices" +) // The support bundle's inclusion policy (RFC 0024 §3.2). // @@ -279,10 +282,6 @@ func SupportRefusedPaths(p Paths) []string { seen[path] = true } } - out := make([]string, 0, len(seen)) - for path := range seen { - out = append(out, path) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(seen)) return out } diff --git a/internal/infra/atomicfs/archive.go b/internal/infra/atomicfs/archive.go index 5c23ba77..40204681 100644 --- a/internal/infra/atomicfs/archive.go +++ b/internal/infra/atomicfs/archive.go @@ -8,6 +8,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "github.com/klauspost/compress/zstd" @@ -26,12 +27,9 @@ var TarZstExtensions = []string{".tar.zst", ".tzst"} // IsTarZst reports whether a path names a zstd-compressed tar archive. func IsTarZst(path string) bool { lower := strings.ToLower(path) - for _, ext := range TarZstExtensions { - if strings.HasSuffix(lower, ext) { - return true - } - } - return false + return slices.ContainsFunc(TarZstExtensions, func(ext string) bool { + return strings.HasSuffix(lower, ext) + }) } // decoderMaxMemory bounds the zstd window a single frame may ask for. diff --git a/internal/infra/exec/exec.go b/internal/infra/exec/exec.go index 138da607..ddb1bf45 100644 --- a/internal/infra/exec/exec.go +++ b/internal/infra/exec/exec.go @@ -13,8 +13,10 @@ import ( "errors" "fmt" "io" + "maps" "os" osexec "os/exec" + "slices" "strings" "sync" "sync/atomic" @@ -580,7 +582,7 @@ func MergeEnv(env []string, overrides map[string]string) []string { index[kv[:eq]] = i } } - keys := sortedKeys(overrides) + keys := slices.Sorted(maps.Keys(overrides)) for _, k := range keys { entry := k + "=" + overrides[k] if i, ok := index[k]; ok { @@ -591,18 +593,3 @@ func MergeEnv(env []string, overrides map[string]string) []string { } return out } - -func sortedKeys(m map[string]string) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - // Insertion sort: these maps hold a couple of dozen entries at most, - // and this keeps the package free of a sort import for one call. - for i := 1; i < len(out); i++ { - for j := i; j > 0 && out[j] < out[j-1]; j-- { - out[j], out[j-1] = out[j-1], out[j] - } - } - return out -} diff --git a/internal/infra/exec/redact.go b/internal/infra/exec/redact.go index 4b4aa2ae..53f7fb80 100644 --- a/internal/infra/exec/redact.go +++ b/internal/infra/exec/redact.go @@ -64,9 +64,3 @@ func (r *redactor) strings(in []string) []string { } return out } - -// Redact scrubs values from a string. Exported for the logging handler, which -// applies the same rule to structured log records. -func Redact(s string, values []string) string { - return newRedactor(values).string(s) -} diff --git a/internal/infra/exec/scripted.go b/internal/infra/exec/scripted.go index 309f97e0..bf1b64a8 100644 --- a/internal/infra/exec/scripted.go +++ b/internal/infra/exec/scripted.go @@ -3,6 +3,7 @@ package exec import ( "context" "fmt" + "slices" "strings" "sync" "time" @@ -151,12 +152,9 @@ func (s *Scripted) Calls() []Command { // Ran reports whether any command's line contains match. func (s *Scripted) Ran(match string) bool { - for _, c := range s.Calls() { - if strings.Contains(strings.Join(c.Argv, " "), match) { - return true - } - } - return false + return slices.ContainsFunc(s.Calls(), func(c Command) bool { + return strings.Contains(strings.Join(c.Argv, " "), match) + }) } // CommandLines renders every call, for a failure message that says what the diff --git a/internal/infra/logging/logging.go b/internal/infra/logging/logging.go index 3949d205..f6b5b44e 100644 --- a/internal/infra/logging/logging.go +++ b/internal/infra/logging/logging.go @@ -11,9 +11,9 @@ import ( "fmt" "io" "log/slog" + "slices" "strings" "sync" - "time" "github.com/morzecrew/morzer/internal/domain" "github.com/morzecrew/morzer/internal/events" @@ -92,7 +92,7 @@ func (r *Redactor) Register(values ...string) { if len(v) < minRedactLength { continue } - if !contains(r.values, v) { + if !slices.Contains(r.values, v) { r.values = append(r.values, v) } } @@ -154,15 +154,6 @@ func (r *Redactor) ApplyCount(s string) (string, int) { const minRedactLength = 6 -func contains(hs []string, needle string) bool { - for _, h := range hs { - if h == needle { - return true - } - } - return false -} - // redactingHandler wraps another handler and scrubs both the message and every // string attribute value. type redactingHandler struct { @@ -335,16 +326,3 @@ func ParseLevel(verbose, quiet bool) slog.Level { return slog.LevelInfo } } - -// Clock is injected wherever timestamps affect behaviour, so tests can make -// time deterministic instead of sleeping. -type Clock interface { - Now() time.Time -} - -type realClock struct{} - -func (realClock) Now() time.Time { return time.Now() } - -// SystemClock is the production clock. -var SystemClock Clock = realClock{} diff --git a/internal/lifecycle/engine/engine.go b/internal/lifecycle/engine/engine.go index 3d95f71b..e9519393 100644 --- a/internal/lifecycle/engine/engine.go +++ b/internal/lifecycle/engine/engine.go @@ -66,9 +66,6 @@ func New(state ports.StateStore, bus *events.Bus) *Engine { return &Engine{state: state, bus: bus} } -// Bus exposes the event bus so presenters can subscribe. -func (e *Engine) Bus() *events.Bus { return e.bus } - // Result is the outcome of a run. type Result struct { Record domain.OperationRecord diff --git a/internal/lifecycle/engine/step.go b/internal/lifecycle/engine/step.go index 48f5502a..170cc9ea 100644 --- a/internal/lifecycle/engine/step.go +++ b/internal/lifecycle/engine/step.go @@ -37,19 +37,6 @@ const ( Continue ) -func (p FailurePolicy) String() string { - switch p { - case Abort: - return "abort" - case Compensate: - return "compensate" - case Continue: - return "continue" - default: - return "unknown" - } -} - // Step is one unit of work. // // The four functions separate concerns that are usually tangled: Check asks @@ -176,14 +163,6 @@ func (s *State) Detail(format string, args ...any) { s.Progress(-1, fmt.Sprintf(format, args...)) } -// Output forwards a line of subprocess output to the live view. -func (s *State) Output(line string) { - if s.bus == nil { - return - } - s.bus.Publish(events.StepOutput(s.OpID, s.stepID, line)) -} - // Warn emits a warning that is not a failure. func (s *State) Warn(format string, args ...any) { if s.bus == nil { diff --git a/internal/lifecycle/ops/apply.go b/internal/lifecycle/ops/apply.go index 06d1b989..76df9b99 100644 --- a/internal/lifecycle/ops/apply.go +++ b/internal/lifecycle/ops/apply.go @@ -268,12 +268,7 @@ func (d *Deps) projectRunning(ctx context.Context, inst domain.Installation, rel if err != nil { return false } - for _, s := range states { - if s.Running() { - return true - } - } - return false + return anyRunning(states) } // stepLoadSecrets decrypts the secret state into memory. diff --git a/internal/lifecycle/ops/attest.go b/internal/lifecycle/ops/attest.go index 815e13ba..da3895b7 100644 --- a/internal/lifecycle/ops/attest.go +++ b/internal/lifecycle/ops/attest.go @@ -4,8 +4,9 @@ import ( "context" "encoding/json" "fmt" + "maps" "path/filepath" - "sort" + "slices" "strings" "github.com/morzecrew/morzer/internal/domain" @@ -268,11 +269,7 @@ func attestationInputs( // the running state could never disagree with it, and RFC 0025 decision 8 makes // the design conditional on that disagreement being possible. func attestedImages(rel domain.Release) []domain.AttestedImage { - names := make([]string, 0, len(rel.Manifest.Images)) - for name := range rel.Manifest.Images { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(rel.Manifest.Images)) out := make([]domain.AttestedImage, 0, len(names)) for _, name := range names { diff --git a/internal/lifecycle/ops/attest_push.go b/internal/lifecycle/ops/attest_push.go index 6857bd2e..17b28baf 100644 --- a/internal/lifecycle/ops/attest_push.go +++ b/internal/lifecycle/ops/attest_push.go @@ -8,6 +8,7 @@ import ( "os" "path" "path/filepath" + "slices" "sort" "strings" @@ -164,12 +165,7 @@ type AttestTargetStatus struct { // Unreachable reports whether anything went wrong anywhere. func (r AttestPushReport) Unreachable() bool { - for _, t := range r.Targets { - if t.Error != "" { - return true - } - } - return false + return slices.ContainsFunc(r.Targets, func(t AttestTargetStatus) bool { return t.Error != "" }) } // Missing totals the statements that are not on some target. diff --git a/internal/lifecycle/ops/config.go b/internal/lifecycle/ops/config.go index 5b7a50cc..96d96008 100644 --- a/internal/lifecycle/ops/config.go +++ b/internal/lifecycle/ops/config.go @@ -2,6 +2,8 @@ package ops import ( "context" + "maps" + "slices" "sort" "strings" "time" @@ -454,10 +456,6 @@ func notDeclared(name string, declared []string) error { } func sortedNames(m map[string]domain.ParameterSpec) []string { - out := make([]string, 0, len(m)) - for name := range m { - out = append(out, name) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(m)) return out } diff --git a/internal/lifecycle/ops/doctor_machine.go b/internal/lifecycle/ops/doctor_machine.go index 0a1ea665..71f7491c 100644 --- a/internal/lifecycle/ops/doctor_machine.go +++ b/internal/lifecycle/ops/doctor_machine.go @@ -3,6 +3,7 @@ package ops import ( "context" "fmt" + "maps" "slices" "sort" "strings" @@ -230,10 +231,7 @@ func (d *Deps) declaredPorts(ctx context.Context) (map[int][]string, []string) { // otherwise reorder a warning between two runs of the same command, which is // what makes a diff of two `doctor --json` outputs unreadable. func sortedPorts(claims map[int][]string) []int { - out := make([]int, 0, len(claims)) - for port := range claims { - out = append(out, port) - } + out := slices.Collect(maps.Keys(claims)) sort.Ints(out) return out } diff --git a/internal/lifecycle/ops/fleet_publish.go b/internal/lifecycle/ops/fleet_publish.go index 7a68aa80..9224abc4 100644 --- a/internal/lifecycle/ops/fleet_publish.go +++ b/internal/lifecycle/ops/fleet_publish.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io/fs" + "slices" "strings" @@ -79,12 +80,7 @@ type FleetPublishTarget struct { // Unreachable reports whether any target refused the row. func (r FleetPublishReport) Unreachable() bool { - for _, t := range r.Targets { - if t.Error != "" { - return true - } - } - return false + return slices.ContainsFunc(r.Targets, func(t FleetPublishTarget) bool { return t.Error != "" }) } // FleetPublish writes this installation's row to every configured target. diff --git a/internal/lifecycle/ops/recovery.go b/internal/lifecycle/ops/recovery.go index a391a8c6..8c78a6e2 100644 --- a/internal/lifecycle/ops/recovery.go +++ b/internal/lifecycle/ops/recovery.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "slices" "strings" "time" @@ -200,12 +201,9 @@ func ExportForBackup(ctx context.Context, d *Deps) (domain.InstallationExport, b } func hasRecoveryRecipient(in []ports.Recipient) bool { - for _, r := range in { - if r.Kind == ports.RecipientRecovery { - return true - } - } - return false + return slices.ContainsFunc(in, func(r ports.Recipient) bool { + return r.Kind == ports.RecipientRecovery + }) } // exportRecipients converts port recipients into the document's own diff --git a/internal/lifecycle/ops/settings.go b/internal/lifecycle/ops/settings.go index 782f9cdb..721fc031 100644 --- a/internal/lifecycle/ops/settings.go +++ b/internal/lifecycle/ops/settings.go @@ -3,7 +3,8 @@ package ops import ( "context" "errors" - "fmt" + "maps" + "slices" "sort" "strconv" "strings" @@ -156,11 +157,7 @@ func (d *Deps) checkChannelIsFollowable(ctx context.Context, ref ports.Ref) erro // SettingNames lists what may be set, sorted. func SettingNames() []string { - out := make([]string, 0, len(settings)) - for name := range settings { - out = append(out, name) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(settings)) return out } @@ -493,11 +490,7 @@ func zeroFor(inst domain.Installation, s setting) domain.Installation { // sortedSettingNames orders a change so two runs report the same list, and so // a failure part-way through fails on the same setting each time. func sortedSettingNames(m map[string]string) []string { - out := make([]string, 0, len(m)) - for name := range m { - out = append(out, name) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(m)) return out } @@ -505,16 +498,3 @@ func unknownSetting(name string) error { return domain.Usage("no installation setting named %q", name). WithHint("settable: %s", strings.Join(SettingNames(), ", ")) } - -// DescribeSettings renders the list for a human, one per line. -func DescribeSettings(report SettingsReport) string { - var sb strings.Builder - for _, entry := range report.Settings { - value := entry.Value - if value == "" { - value = "(unset)" - } - fmt.Fprintf(&sb, "%-16s %-24s %s\n", entry.Name, value, entry.Description) - } - return strings.TrimRight(sb.String(), "\n") -} diff --git a/internal/lifecycle/ops/status.go b/internal/lifecycle/ops/status.go index 9a4965ea..768a05e4 100644 --- a/internal/lifecycle/ops/status.go +++ b/internal/lifecycle/ops/status.go @@ -3,6 +3,7 @@ package ops import ( "context" "os" + "slices" "time" "github.com/morzecrew/morzer/internal/domain" @@ -223,12 +224,7 @@ func (d *Deps) fillRuntimeStatus(ctx context.Context, out *Status, inst domain.I } func anyRunning(services []ports.ServiceState) bool { - for _, s := range services { - if s.Running() { - return true - } - } - return false + return slices.ContainsFunc(services, ports.ServiceState.Running) } func (d *Deps) fillBackupStatus(ctx context.Context, out *Status, inst domain.Installation) { diff --git a/internal/lifecycle/ops/support.go b/internal/lifecycle/ops/support.go index 5ba5d3ba..70fda930 100644 --- a/internal/lifecycle/ops/support.go +++ b/internal/lifecycle/ops/support.go @@ -6,10 +6,10 @@ import ( "errors" "fmt" "io/fs" + "maps" "os" "path/filepath" "slices" - "sort" "strings" "time" @@ -312,11 +312,7 @@ func collectLogs(ctx context.Context, d *Deps, _ *supportSource) ([]supportFile, "the deployment produced no log output to capture") } - names := make([]string, 0, len(perService)) - for name := range perService { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(perService)) out := make([]supportFile, 0, len(names)) for _, name := range names { @@ -915,11 +911,7 @@ func configComparison( return ConfigComparison{}, err } - targets := make([]string, 0, len(rendered)) - for target := range rendered { - targets = append(targets, target) - } - sort.Strings(targets) + targets := slices.Sorted(maps.Keys(rendered)) var out ConfigComparison for _, target := range targets { diff --git a/internal/lifecycle/preflight/preflight.go b/internal/lifecycle/preflight/preflight.go index be2c66b8..981a6d85 100644 --- a/internal/lifecycle/preflight/preflight.go +++ b/internal/lifecycle/preflight/preflight.go @@ -10,11 +10,12 @@ package preflight import ( "context" "fmt" + "maps" "math" "net" "os" "runtime" - "sort" + "slices" "strconv" "strings" "time" @@ -283,11 +284,7 @@ func Tool(registry *tools.Registry, name string, constraint domain.Constraint) C // Tools builds a check per entry in requirements.tools, in a stable order. func Tools(registry *tools.Registry, req domain.Requirements) []Check { - names := make([]string, 0, len(req.Tools)) - for name := range req.Tools { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(req.Tools)) checks := make([]Check, 0, len(names)) for _, name := range names { diff --git a/internal/release/notes.go b/internal/release/notes.go index d48b1253..0c288145 100644 --- a/internal/release/notes.go +++ b/internal/release/notes.go @@ -5,6 +5,9 @@ import ( "os" "path/filepath" "strings" + "unicode" + + "github.com/charmbracelet/x/ansi" "github.com/morzecrew/morzer/internal/domain" ) @@ -69,10 +72,40 @@ func Notes(rel domain.Release) string { return "" } if len(raw) > maxNotesBytes { - return string(raw[:maxNotesBytes]) + + return stripTerminalControls(string(raw[:maxNotesBytes])) + "\n\n_(release notes truncated at 256 KiB)_\n" } - return string(raw) + return stripTerminalControls(string(raw)) +} + +// stripTerminalControls removes what a terminal acts on rather than prints. +// +// These bytes are the vendor's, and every path out of here ends somewhere that +// interprets them: `update --check` and `update --stage` write them to stderr, +// and NotesSummary puts the first line into a notification body that leaves the +// machine. A release note is prose, so nothing legitimate is lost -- and this +// project already withholds raw vendor output from notifiers for exactly this +// reason (ops.forwardedKinds refuses KindStepOutput), which the notes path was +// quietly bypassing. +// +// Two passes, because they catch different things. ansi.Strip removes whole +// escape sequences -- OSC (a window title, or OSC 52 writing the operator's +// clipboard), CSI, SGR, DCS -- which is what a naive control-rune filter cannot +// do: dropping the ESC alone leaves `]0;...` on screen as garbage. The rune pass +// then removes the bare C0 and C1 controls ansi.Strip leaves behind, where the +// ones that matter are CR and backspace: both let a vendor overwrite a line the +// operator has already read, which is how text that looked safe stops being what +// is there. Newline and tab are the two a note legitimately contains. +func stripTerminalControls(s string) string { + return strings.Map(func(r rune) rune { + if r == '\n' || r == '\t' { + return r + } + if unicode.IsControl(r) { + return -1 + } + return r + }, ansi.Strip(s)) } // maxSummaryRunes bounds the one-liner. diff --git a/internal/release/notes_test.go b/internal/release/notes_test.go index 40d21244..4418bb26 100644 --- a/internal/release/notes_test.go +++ b/internal/release/notes_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "unicode" "github.com/morzecrew/morzer/internal/domain" "github.com/morzecrew/morzer/internal/release" @@ -138,3 +139,70 @@ func TestTheSummarySkipsHeadingsAndIsShortEnoughToSend(t *testing.T) { t.Errorf("a file with nothing but headings summarised as %q", got) } } + +// TestNotesCannotDriveTheTerminalOrTheClipboard. +// +// The notes are the vendor's bytes and every path out of `Notes` ends somewhere +// that interprets them: stderr on `update --check` and `update --stage`, and a +// notification body through NotesSummary. A bundle that ships an OSC 52 in its +// RELEASE.md would otherwise write the operator's clipboard the moment they +// asked what an update changes. +// +// Carriage return and backspace are in here for the reason that is easy to miss: +// they are not escape sequences, so an ANSI stripper leaves them, and either one +// lets a vendor overwrite a line the operator has already read. +func TestNotesCannotDriveTheTerminalOrTheClipboard(t *testing.T) { + // wantGone is the sequence's payload, not just its ESC. Asserting only + // that no control rune survives is too weak: dropping the ESC alone + // satisfies it and leaves `]52;c;cHduZWQ=` on the operator's screen as + // text. That version of this test passed against a stripper that had + // been removed, which is how this column got here. + for _, tc := range []struct { + name, body, wantKept, wantGone string + }{ + {"OSC 52 clipboard write", "\x1b]52;c;cHduZWQ=\x07keep", "keep", "cHduZWQ="}, + {"OSC window title, BEL-terminated", "\x1b]0;owned\x07keep", "keep", "owned"}, + {"OSC window title, ST-terminated", "\x1b]0;owned\x1b\\keep", "keep", "owned"}, + {"SGR colour", "\x1b[31mkeep\x1b[0m", "keep", "31m"}, + {"cursor movement", "\x1b[2Akeep", "keep", "[2A"}, + {"DCS", "\x1bP0;1|x\x1b\\keep", "keep", "0;1|x"}, + {"carriage return overwrite", "hidden\rkeep", "keep", ""}, + {"backspace overwrite", "x\bkeep", "keep", ""}, + {"bell", "\x07keep", "keep", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + rel := releaseAt(t, map[string]string{"RELEASE.md": tc.body}) + rel.Manifest.Metadata.ReleaseNotes = "RELEASE.md" + + got := release.Notes(rel) + + if strings.ContainsFunc(got, func(r rune) bool { + return r != '\n' && r != '\t' && unicode.IsControl(r) + }) { + t.Errorf("a control character survived into what reaches a terminal: %q", got) + } + if !strings.Contains(got, tc.wantKept) { + t.Errorf("the visible text was lost: %q", got) + } + if tc.wantGone != "" && strings.Contains(got, tc.wantGone) { + t.Errorf("the sequence lost its ESC but kept its payload %q: %q", + tc.wantGone, got) + } + }) + } +} + +// TestNotesKeepEveryMarkdownConstruct. The filter above must not be a renderer: +// what a vendor wrote is what an operator reads, and the fenced command in here +// is the one they copy. +func TestNotesKeepEveryMarkdownConstruct(t *testing.T) { + body := "# demo 1.4.0\n\n- one\n- two\n\n```sh\nmorzer apply --wait-for-health\n```\n\n" + + "| setting | old | new |\n| --- | --- | --- |\n| pool | 10 | 40 |\n\n" + + "See https://example.com/a/b?c=d&e=f\n\n\tindented\n" + rel := releaseAt(t, map[string]string{"RELEASE.md": body}) + rel.Manifest.Metadata.ReleaseNotes = "RELEASE.md" + + if got := release.Notes(rel); got != body { + t.Errorf("the notes were rewritten:\nwant %q\ngot %q", body, got) + } +} diff --git a/internal/ui/measure.go b/internal/ui/measure.go index 4f93bba4..65326fda 100644 --- a/internal/ui/measure.go +++ b/internal/ui/measure.go @@ -21,10 +21,9 @@ import ( // explaining it 207 spaces apart on a 380-column screen. // // 100 rather than 80 because the tables here carry identifiers and versions -// that 80 genuinely cannot hold, and because RenderNotes already holds the -// 80-column line for prose. Typographic practice puts a comfortable measure at -// 45-75 characters; glamour, already a dependency, defaults to 80. A hundred is -// the widest that is still one measure rather than a screen. +// that 80 genuinely cannot hold. Typographic practice puts a comfortable +// measure at 45-75 characters; a hundred is the widest that is still one +// measure rather than a screen. const MaxContentWidth = 100 // Gutter is the space between two columns, everywhere. diff --git a/internal/ui/mode.go b/internal/ui/mode.go index 4b073b70..a3f5d000 100644 --- a/internal/ui/mode.go +++ b/internal/ui/mode.go @@ -9,6 +9,7 @@ package ui import ( "io" "os" + "strconv" "strings" ) @@ -201,16 +202,19 @@ func TerminalWidth() int { return CurrentScreen().Width } // pipe, which is the one place where nothing was ever going to be truncated. const fallbackWidth = 100 +// atoiSafe parses a width, answering 0 for anything that is not a plain +// non-negative number under the ceiling -- COLUMNS is operator-set and a +// nonsense value must fall back rather than fail. +// +// The sign is refused rather than read. `strconv.Atoi` accepts "+80", and +// nothing that legitimately sets COLUMNS writes it that way; a value shaped +// like that is a value somebody's script built wrong, and a guessed fallback +// is the safer answer than half-understanding it. func atoiSafe(s string) int { - n := 0 - for _, r := range strings.TrimSpace(s) { - if r < '0' || r > '9' { - return 0 - } - n = n*10 + int(r-'0') - if n > 10000 { - return 0 - } + s = strings.TrimSpace(s) + n, err := strconv.Atoi(s) + if err != nil || n < 0 || n > 10000 || strings.HasPrefix(s, "+") { + return 0 } return n } diff --git a/internal/ui/notes.go b/internal/ui/notes.go deleted file mode 100644 index 3aa7586b..00000000 --- a/internal/ui/notes.go +++ /dev/null @@ -1,51 +0,0 @@ -package ui - -import ( - "strings" - - "github.com/charmbracelet/glamour" -) - -// notesWidth is where release notes wrap. -// -// Fixed rather than measured from the terminal: these are printed after an -// operation from a command that may be running under a timer, in a pipe or in a -// CI log, where "the terminal" is 80 columns by convention and nothing else. -const notesWidth = 80 - -// RenderNotes turns a bundle's release notes into something worth reading. -// -// This is RFC 0002's P5, which sat unbuilt for months behind "gated on a bundle -// actually shipping a RELEASE.md" -- a gate nothing in the project could open, -// because no scaffold wrote such a file and no page mentioned one. It lands here -// because two other changes opened it: `release new` now writes the stub, and a -// staged-but-unapplied release is a moment where "what changes" is the question -// an operator is actually asking. -// -// Markdown is rendered only in rich mode. Plain mode is defined as line-oriented -// output that is stable in a log, and ANSI colour in a journal entry is noise -// that outlives the terminal that wanted it -- so plain gets the source text, -// which is what a vendor wrote and is readable on its own. -// -// A rendering failure returns the source rather than an error. These are notes: -// failing an update check because a vendor's Markdown upset a parser would be -// the tail wagging the dog. -func RenderNotes(mode Mode, notes string) string { - notes = strings.TrimSpace(notes) - if notes == "" || mode != ModeRich { - return notes - } - - renderer, err := glamour.NewTermRenderer( - glamour.WithAutoStyle(), - glamour.WithWordWrap(notesWidth), - ) - if err != nil { - return notes - } - out, err := renderer.Render(notes) - if err != nil { - return notes - } - return strings.Trim(out, "\n") -} diff --git a/internal/ui/notes_test.go b/internal/ui/notes_test.go deleted file mode 100644 index 4bd29e29..00000000 --- a/internal/ui/notes_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package ui_test - -import ( - "strings" - "testing" - - "github.com/morzecrew/morzer/internal/ui" -) - -const releaseNotes = "# demo 1.4.0\n\nFixes the thing that broke.\n\n- one\n- two\n" - -// TestPlainModeGetsTheSourceText. -// -// Plain output is defined as line-oriented and stable in a log, and ANSI colour -// in a journal entry outlives the terminal that wanted it. What a vendor wrote -// is already readable, so plain mode passes it through rather than degrading it -// into something else. -func TestPlainModeGetsTheSourceText(t *testing.T) { - got := ui.RenderNotes(ui.ModePlain, releaseNotes) - - if got != strings.TrimSpace(releaseNotes) { - t.Errorf("plain mode rewrote the notes:\n%q", got) - } -} - -// TestRichModeRendersAndKeepsTheContent. -// -// The assertion is on the words rather than on the escape codes: which colours -// glamour chooses is its business and changes between versions, but notes that -// arrive rendered and *missing a line* would be a renderer quietly dropping what -// an operator is deciding on. -func TestRichModeRendersAndKeepsTheContent(t *testing.T) { - got := ui.RenderNotes(ui.ModeRich, releaseNotes) - - if got == "" { - t.Fatal("rich mode rendered release notes to nothing") - } - for _, want := range []string{"demo 1.4.0", "Fixes the thing that broke", "one", "two"} { - if !strings.Contains(got, want) { - t.Errorf("the rendered notes lost %q:\n%s", want, got) - } - } -} - -// TestNothingRendersToNothing. -// -// A bundle without release notes renders nothing at all, exactly as `release -// show` behaves. The caller prints only what comes back, so an empty string is -// what keeps a blank section out of the output. -func TestNothingRendersToNothing(t *testing.T) { - for _, mode := range []ui.Mode{ui.ModeRich, ui.ModePlain, ui.ModeJSON} { - if got := ui.RenderNotes(mode, " \n\n"); got != "" { - t.Errorf("%s rendered whitespace-only notes as %q", mode, got) - } - } -} - -// TestJSONModeIsNotStyled. -// -// `--json` is a machine contract, and a mode that decided to colour its stderr -// because the notes happened to be Markdown would put escape codes into -// whatever is reading it. -func TestJSONModeIsNotStyled(t *testing.T) { - got := ui.RenderNotes(ui.ModeJSON, releaseNotes) - - if strings.Contains(got, "\x1b[") { - t.Errorf("json mode styled the notes:\n%q", got) - } -} diff --git a/internal/ui/screen_test.go b/internal/ui/screen_test.go index 688680ab..08ac4a74 100644 --- a/internal/ui/screen_test.go +++ b/internal/ui/screen_test.go @@ -45,7 +45,7 @@ func TestAnExportedCOLUMNSWinsOverTheDestination(t *testing.T) { require.Equal(t, 137, got.Width) // Malformed values are ignored rather than parsed to something absurd. - for _, bad := range []string{"", "0", "-1", "80x24", "abc", "10001"} { + for _, bad := range []string{"", "0", "-1", "+80", "80x24", "abc", "10001"} { t.Setenv("COLUMNS", bad) require.Falsef(t, ui.ScreenFor(&bytes.Buffer{}).Known, "COLUMNS=%q was taken as a width", bad) diff --git a/internal/ui/views/release.go b/internal/ui/views/release.go index efe3871a..ef3a1aa4 100644 --- a/internal/ui/views/release.go +++ b/internal/ui/views/release.go @@ -3,7 +3,8 @@ package views import ( "fmt" "io" - "sort" + "maps" + "slices" "strings" "github.com/morzecrew/morzer/internal/domain" @@ -67,11 +68,7 @@ func runtimeSummary(m domain.Manifest) string { parts = append(parts, name) continue } - keys := make([]string, 0, len(options)) - for key := range options { - keys = append(keys, key) - } - sort.Strings(keys) + keys := slices.Sorted(maps.Keys(options)) settings := make([]string, 0, len(keys)) for _, key := range keys { settings = append(settings, key+"="+options[key]) @@ -100,7 +97,7 @@ func releaseDoc(d *ui.Doc, r Release) *ui.Doc { d.Heading("images") rows := make([][]string, 0, len(m.Images)) - for _, name := range sortedKeys(m.Images) { + for _, name := range slices.Sorted(maps.Keys(m.Images)) { rows = append(rows, []string{name, m.Images[name].Ref}) } d.Table(4, ui.Table{ diff --git a/internal/ui/views/reports.go b/internal/ui/views/reports.go index 9a27022a..5c4862e6 100644 --- a/internal/ui/views/reports.go +++ b/internal/ui/views/reports.go @@ -3,7 +3,6 @@ package views import ( "fmt" "io" - "sort" "strings" "github.com/morzecrew/morzer/internal/lifecycle/ops" @@ -131,14 +130,3 @@ func settingsDoc(d *ui.Doc, report ops.SettingsReport) *ui.Doc { }) return d } - -// sortedKeys is the ordering every map-derived listing uses, so two runs against -// the same release print the same bytes. -func sortedKeys[V any](m map[string]V) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/test/contract/runtime.go b/test/contract/runtime.go index f0af1ed4..eb2b9bee 100644 --- a/test/contract/runtime.go +++ b/test/contract/runtime.go @@ -10,6 +10,7 @@ import ( "maps" "os" "path/filepath" + "slices" "sort" "strings" "testing" @@ -298,12 +299,7 @@ func runQuiesceSuite(t *testing.T, newRuntime RuntimeFactory) { // anyOccupies reports whether any service still holds its volumes open. func anyOccupies(states []ports.ServiceState) bool { - for _, s := range states { - if s.OccupiesVolume() { - return true - } - } - return false + return slices.ContainsFunc(states, ports.ServiceState.OccupiesVolume) } // runVolumeSuite covers the optional volume capabilities. @@ -614,22 +610,14 @@ func parentDirs(files map[string]string) []string { } } - out := make([]string, 0, len(set)) - for dir := range set { - out = append(out, dir) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(set)) return out } // sortedNames keeps the archive deterministic: a tarball that differed between // runs would make a failure here impossible to compare against the last one. func sortedNames(files map[string]string) []string { - out := make([]string, 0, len(files)) - for name := range files { - out = append(out, name) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(files)) return out } diff --git a/test/fakes/fakes.go b/test/fakes/fakes.go index aaeb2cc0..b14d1ec4 100644 --- a/test/fakes/fakes.go +++ b/test/fakes/fakes.go @@ -15,6 +15,7 @@ import ( "io" "maps" "os" + "slices" "sort" "strings" "sync" @@ -342,10 +343,7 @@ func (r *Runtime) stopTargets(services []string) []string { if len(services) > 0 { return services } - out := make([]string, 0, len(r.Services)) - for name := range r.Services { - out = append(out, name) - } + out := slices.Collect(maps.Keys(r.Services)) return out } diff --git a/test/fakes/target.go b/test/fakes/target.go index ebd7a1ea..572205c7 100644 --- a/test/fakes/target.go +++ b/test/fakes/target.go @@ -5,6 +5,8 @@ import ( "context" "io" "io/fs" + "maps" + "slices" "sort" "strings" "sync" @@ -149,11 +151,7 @@ func (t *BackupTarget) Objects() []string { t.mu.Lock() defer t.mu.Unlock() - out := make([]string, 0, len(t.objects)) - for key := range t.objects { - out = append(out, key) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(t.objects)) return out } diff --git a/test/suite/channel_test.go b/test/suite/channel_test.go index 81e1ebe5..297918fa 100644 --- a/test/suite/channel_test.go +++ b/test/suite/channel_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "sync" "testing" @@ -179,6 +180,44 @@ func TestAMovedChannelStagesTheReleaseWithoutInstallingIt(t *testing.T) { "the staged release ships RELEASE.md; nothing read it") } +// theCommandAVendorWrote is one line, longer than any measure this project +// holds prose to, inside a fenced block. +const theCommandAVendorWrote = "morzer apply --installation demo --profile production " + + "--release 1.3.0 --wait-for-health --timeout 15m" + +// TestStagedNotesArriveAsTheVendorWroteThem. +// +// Release notes are a vendor's Markdown, and nothing between the bundle and the +// operator reflows them. A wrap inserted at a column splits the fenced command +// below, and the operator who copies it gets something that does not run -- +// which is what a word wrapper applied to Markdown did, before it was removed +// in favour of not transforming the notes at all. +// +// The assertion is the whole line in one Contains. Asserting on the words would +// pass against output that had broken the line between them. +func TestStagedNotesArriveAsTheVendorWroteThem(t *testing.T) { + notes := "# demo 1.3.0\n\nRun this afterwards:\n\n```sh\n" + + theCommandAVendorWrote + "\n```\n" + + h, registry, _, _ := followingHarnessWith(t, func(t *testing.T, bundleDir string) { + t.Helper() + require.NoError(t, os.WriteFile( + filepath.Join(bundleDir, "RELEASE.md"), []byte(notes), 0o644)) + }) + + // The tag this harness publishes is the one the installation already + // follows, so the poll needs no explicit reference. + _ = registry + result, err := ops.FollowChannel(context.Background(), h.Deps, + ops.FollowChannelOptions{Explicit: true}) + require.NoError(t, err) + + assert.Contains(t, result.Notes, theCommandAVendorWrote, + "the fenced command reached the operator broken across lines") + assert.Equal(t, strings.TrimSpace(notes), strings.TrimSpace(result.Notes), + "something between the bundle and the operator rewrote the notes") +} + // TestInstallingAStagedReleaseRetiresTheCandidate. // // Two halves. The candidate is forgotten, so `status` stops offering a release diff --git a/test/suite/ingest_docker_test.go b/test/suite/ingest_docker_test.go index b86792da..77257be7 100644 --- a/test/suite/ingest_docker_test.go +++ b/test/suite/ingest_docker_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "oras.land/oras-go/v2" "oras.land/oras-go/v2/registry/remote" "github.com/morzecrew/morzer/internal/adapters/imagepack" @@ -79,7 +80,7 @@ func bundleWithImage(t *testing.T) (layoutDir, vendorRef string) { bundle := t.TempDir() packer, err := imagepack.New("") require.NoError(t, err) - packer = packer.WithSource(func(string) (imagepack.Source, error) { + packer = packer.WithSource(func(string) (oras.ReadOnlyTarget, error) { repo, err := remote.NewRepository(addr + "/morzer-test/app") if err != nil { return nil, err diff --git a/test/suite/steps_test.go b/test/suite/steps_test.go index f671bdf0..79895628 100644 --- a/test/suite/steps_test.go +++ b/test/suite/steps_test.go @@ -2,8 +2,10 @@ package suite import ( "context" + "maps" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -580,9 +582,6 @@ func TestExportNeedsAStoreThatCanExport(t *testing.T) { } func keysOf(m map[string][]byte) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } + out := slices.Collect(maps.Keys(m)) return out } diff --git a/test/suite/supervisor_contract_test.go b/test/suite/supervisor_contract_test.go index 14605eb9..ddaabe71 100644 --- a/test/suite/supervisor_contract_test.go +++ b/test/suite/supervisor_contract_test.go @@ -1,6 +1,8 @@ package suite import ( + "maps" + "slices" "testing" "github.com/morzecrew/morzer/internal/adapters/supervisor/systemd" @@ -42,10 +44,7 @@ func TestTheSystemdAdapterHonoursTheEnablementRules(t *testing.T) { delete(on, c.Argv[2]) } } - out := make([]string, 0, len(on)) - for name := range on { - out = append(out, name) - } + out := slices.Collect(maps.Keys(on)) return out }, } diff --git a/test/suite/support_test.go b/test/suite/support_test.go index 8b5cd73b..608056eb 100644 --- a/test/suite/support_test.go +++ b/test/suite/support_test.go @@ -5,8 +5,10 @@ import ( "context" "errors" "io" + "maps" "os" "path/filepath" + "slices" "strings" "testing" @@ -301,10 +303,7 @@ func componentNames(r ops.SupportReport) []string { } func keys(m map[string]string) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } + out := slices.Collect(maps.Keys(m)) return out } diff --git a/tools/covmerge/main.go b/tools/covmerge/main.go index 378f1847..6ea13658 100644 --- a/tools/covmerge/main.go +++ b/tools/covmerge/main.go @@ -21,7 +21,9 @@ package main import ( "bufio" "fmt" + "maps" "os" + "slices" "sort" "strconv" "strings" @@ -123,10 +125,7 @@ func read(path string, into map[string]block) error { } func write(path string, blocks map[string]block) error { - keys := make([]string, 0, len(blocks)) - for k := range blocks { - keys = append(keys, k) - } + keys := slices.Collect(maps.Keys(blocks)) // Sorted so the output is byte-identical between runs, which is what // makes a diff of two profiles readable. sort.Strings(keys) diff --git a/tools/docscheck/describe.go b/tools/docscheck/describe.go index 95abffd3..3f48ecb3 100644 --- a/tools/docscheck/describe.go +++ b/tools/docscheck/describe.go @@ -1,10 +1,11 @@ package main import ( + "maps" "os" "path/filepath" "reflect" - "sort" + "slices" "strings" "github.com/morzecrew/morzer/internal/domain" @@ -155,11 +156,7 @@ func installationFieldNames() map[string]string { } func sorted(set map[string]bool) []string { - out := make([]string, 0, len(set)) - for k := range set { - out = append(out, k) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(set)) return out } diff --git a/tools/docscheck/main.go b/tools/docscheck/main.go index 59067a72..f87d35e0 100644 --- a/tools/docscheck/main.go +++ b/tools/docscheck/main.go @@ -49,10 +49,12 @@ import ( "go/parser" "go/token" "io/fs" + "maps" "os" "path/filepath" "reflect" "regexp" + "slices" "sort" "strconv" "strings" @@ -144,11 +146,7 @@ func (r *report) add(group, format string, args ...any) { func (r *report) failed() bool { return len(r.problems) > 0 } func (r *report) print(w *os.File) { - groups := make([]string, 0, len(r.problems)) - for g := range r.problems { - groups = append(groups, g) - } - sort.Strings(groups) + groups := slices.Sorted(maps.Keys(r.problems)) total := 0 for _, g := range groups { @@ -240,12 +238,7 @@ func loadPages(dir string) ([]page, error) { // mentioned reports whether any page names sym as an identifier. func mentioned(pages []page, sym string) bool { - for _, p := range pages { - if p.Code[sym] { - return true - } - } - return false + return slices.ContainsFunc(pages, func(p page) bool { return p.Code[sym] }) } // mentionedIn is mentioned, restricted to one page. Used where a contract has @@ -544,11 +537,7 @@ func checkSchemaFields(rep *report, pages []page) { collectYAMLFields(reflect.TypeOf(domain.Manifest{}), "manifest", fields) collectYAMLFields(reflect.TypeOf(domain.SecretSchema{}), "secret schema", fields) - names := make([]string, 0, len(fields)) - for name := range fields { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(fields)) for _, name := range names { if !mentionedIn(pages, manifestPage, name) { @@ -643,11 +632,7 @@ func checkRuntimeHookVars(rep *report, pages []page) { compose.Name: compose.New(nil), } - names := make([]string, 0, len(suppliers)) - for name := range suppliers { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(suppliers)) for _, name := range names { // A populated configuration, so a supplier that returns nothing diff --git a/tools/runtimecheck/check_test.go b/tools/runtimecheck/check_test.go index f33e4c50..d41b2eed 100644 --- a/tools/runtimecheck/check_test.go +++ b/tools/runtimecheck/check_test.go @@ -1,6 +1,7 @@ package main import ( + "slices" "strings" "testing" ) @@ -372,19 +373,7 @@ func TestAnEntryStopsCoveringWhatItNoLongerCovers(t *testing.T) { } func hasSymbol(found []Finding, symbol string) bool { - for _, f := range found { - if f.Symbol == symbol { - return true - } - } - return false + return slices.ContainsFunc(found, func(f Finding) bool { return f.Symbol == symbol }) } -func containsFinding(list []Finding, want Finding) bool { - for _, f := range list { - if f == want { - return true - } - } - return false -} +func containsFinding(list []Finding, want Finding) bool { return slices.Contains(list, want) }