From 803c3544edc5442b4524ada8f5f26ddf92985337 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 4 May 2026 17:49:38 -0700 Subject: [PATCH] feat(cli): add IncusOS build URL resolution Adds the foundational plan/build/publish command surface, provider boundary, IncusOS catalog adapter, and a deliberately shallow build path that resolves and prints the IncusOS source image URL. --- go.mod | 18 +- go.sum | 52 +++- go.work | 6 + go.work.sum | 15 + internal/cli/build.go | 36 +++ internal/cli/image_config.go | 81 +++++ internal/cli/options.go | 5 + internal/cli/plan.go | 18 ++ internal/cli/publish.go | 18 ++ internal/cli/root.go | 8 +- internal/cli/root_test.go | 157 ++++++++++ internal/providers/doc.go | 3 + internal/providers/incusos/cdn/client.go | 282 ++++++++++++++++++ internal/providers/incusos/cdn/client_test.go | 232 ++++++++++++++ internal/providers/incusos/cdn/doc.go | 2 + internal/providers/incusos/doc.go | 2 + internal/providers/incusos/errors.go | 9 + internal/providers/incusos/provider.go | 150 ++++++++++ internal/providers/incusos/provider_test.go | 236 +++++++++++++++ internal/providers/incusos/types.go | 129 ++++++++ internal/providers/provider.go | 112 +++++++ moon.yml | 2 + schemas/embed_test.go | 110 +++++++ schemas/providers/incusos/cue_types_gen.go | 22 +- schemas/providers/incusos/schema.cue | 22 +- 25 files changed, 1714 insertions(+), 13 deletions(-) create mode 100644 go.work create mode 100644 go.work.sum create mode 100644 internal/cli/build.go create mode 100644 internal/cli/image_config.go create mode 100644 internal/cli/plan.go create mode 100644 internal/cli/publish.go create mode 100644 internal/providers/doc.go create mode 100644 internal/providers/incusos/cdn/client.go create mode 100644 internal/providers/incusos/cdn/client_test.go create mode 100644 internal/providers/incusos/cdn/doc.go create mode 100644 internal/providers/incusos/doc.go create mode 100644 internal/providers/incusos/errors.go create mode 100644 internal/providers/incusos/provider.go create mode 100644 internal/providers/incusos/provider_test.go create mode 100644 internal/providers/incusos/types.go create mode 100644 internal/providers/provider.go diff --git a/go.mod b/go.mod index a2e8694..d40f6eb 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.26 require ( charm.land/log/v2 v2.0.0 + cuelang.org/go v0.16.1 github.com/charmbracelet/colorprofile v0.4.2 + github.com/meigma/imgcli/schemas v0.0.0-20260504225557-fa97d8c3fe0c github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 @@ -13,6 +15,7 @@ require ( require ( charm.land/lipgloss/v2 v2.0.1 // indirect + cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect @@ -20,17 +23,25 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/proto v1.14.3 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -39,8 +50,11 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/sync v0.18.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a027818..102c9ba 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,10 @@ charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= +cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I= +cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= +cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE= +cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= @@ -18,39 +22,61 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= +github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/proto v1.14.3 h1:zEhlzNkpP8kN6utonKMzlPfIvy82t5Kb9mufaJxSe1Q= +github.com/emicklei/proto v1.14.3/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/meigma/imgcli/schemas v0.0.0-20260504225557-fa97d8c3fe0c h1:Uhz9SD1P/JEqDCuAxDL7AKbevYy2CTUP4i+OgFEwkdc= +github.com/meigma/imgcli/schemas v0.0.0-20260504225557-fa97d8c3fe0c/go.mod h1:d5JPNaAIyFEh8Evcgqi4ng1hW6K+BLQNIpbiz4XfX/M= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 h1:2PC6Ql3jipz1KvBlqUHjjk6v4aMwE86mfDu1XMH0LR8= +github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= @@ -77,12 +103,22 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/go.work b/go.work new file mode 100644 index 0000000..fef81f0 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.26 + +use ( + . + ./schemas +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..4d67ebf --- /dev/null +++ b/go.work.sum @@ -0,0 +1,15 @@ +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/cli/build.go b/internal/cli/build.go new file mode 100644 index 0000000..bbbee84 --- /dev/null +++ b/internal/cli/build.go @@ -0,0 +1,36 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/meigma/imgcli/internal/providers" + incusosprovider "github.com/meigma/imgcli/internal/providers/incusos" + "github.com/meigma/imgcli/internal/providers/incusos/cdn" +) + +func newBuildCommand(rt *runtime) *cobra.Command { + return &cobra.Command{ + Use: "build CONFIG", + Short: "Build disk image artifacts from configuration", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + config, err := loadImageConfig(args[0]) + if err != nil { + return err + } + + catalog := rt.opts.IncusOSCatalog + if catalog == nil { + catalog = cdn.NewClient() + } + + provider := incusosprovider.New(*config.Incusos, incusosprovider.Options{ + Catalog: catalog, + Output: rt.opts.stdout(), + }) + + _, err = provider.Build(cmd.Context(), providers.BuildRequest{}) + return err + }, + } +} diff --git a/internal/cli/image_config.go b/internal/cli/image_config.go new file mode 100644 index 0000000..20886cc --- /dev/null +++ b/internal/cli/image_config.go @@ -0,0 +1,81 @@ +package cli + +import ( + "fmt" + "os" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + + imgschemas "github.com/meigma/imgcli/schemas" +) + +func loadImageConfig(path string) (imgschemas.Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return imgschemas.Config{}, fmt.Errorf("read image config %q: %w", path, err) + } + + ctx := cuecontext.New() + input := ctx.CompileBytes(data, cue.Filename(path)) + if inputErr := input.Err(); inputErr != nil { + return imgschemas.Config{}, fmt.Errorf("parse image config %q: %w", path, inputErr) + } + + if providerErr := rejectUnsupportedProviderFields(input); providerErr != nil { + return imgschemas.Config{}, providerErr + } + + schema, err := imgschemas.ConfigSchema(ctx) + if err != nil { + return imgschemas.Config{}, fmt.Errorf("load image config schema: %w", err) + } + + value := schema.Unify(input) + if err := value.Validate(cue.Concrete(false)); err != nil { + return imgschemas.Config{}, fmt.Errorf("validate image config %q: %w", path, err) + } + + var config imgschemas.Config + if err := value.Decode(&config); err != nil { + return imgschemas.Config{}, fmt.Errorf("decode image config %q: %w", path, err) + } + + if config.Incusos == nil { + return imgschemas.Config{}, fmt.Errorf("image config %q must specify provider incusos", path) + } + + return config, nil +} + +func rejectUnsupportedProviderFields(value cue.Value) error { + fields, err := value.Fields() + if err != nil { + return fmt.Errorf("inspect image config fields: %w", err) + } + + for fields.Next() { + selector := fields.Selector() + if selector.LabelType() != cue.StringLabel { + return fmt.Errorf("unsupported image config selector %q", selector) + } + + label := selector.Unquoted() + if isAllowedImageConfigField(label) { + continue + } + + return fmt.Errorf("unsupported provider %q: only incusos is supported", label) + } + + return nil +} + +func isAllowedImageConfigField(label string) bool { + switch label { + case "apiVersion", "kind", "image", "output", "publish", "incusos": + return true + default: + return false + } +} diff --git a/internal/cli/options.go b/internal/cli/options.go index ff7b2b6..4ce689d 100644 --- a/internal/cli/options.go +++ b/internal/cli/options.go @@ -3,6 +3,8 @@ package cli import ( "io" "os" + + "github.com/meigma/imgcli/internal/providers/incusos" ) // Options configures the root imgcli command. @@ -21,6 +23,9 @@ type Options struct { // Environ provides terminal environment values for output adapters. Nil selects os.Environ(). Environ []string + + // IncusOSCatalog resolves IncusOS source images. Nil selects the default CDN catalog. + IncusOSCatalog incusos.Catalog } func (o Options) version() string { diff --git a/internal/cli/plan.go b/internal/cli/plan.go new file mode 100644 index 0000000..b0510f7 --- /dev/null +++ b/internal/cli/plan.go @@ -0,0 +1,18 @@ +package cli + +import ( + "errors" + + "github.com/spf13/cobra" +) + +func newPlanCommand(_ *runtime) *cobra.Command { + return &cobra.Command{ + Use: "plan CONFIG", + Short: "Print the resolved artifact plan", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, _ []string) error { + return errors.New("plan command is not implemented yet") + }, + } +} diff --git a/internal/cli/publish.go b/internal/cli/publish.go new file mode 100644 index 0000000..6b581cd --- /dev/null +++ b/internal/cli/publish.go @@ -0,0 +1,18 @@ +package cli + +import ( + "errors" + + "github.com/spf13/cobra" +) + +func newPublishCommand(_ *runtime) *cobra.Command { + return &cobra.Command{ + Use: "publish CONFIG", + Short: "Build and publish disk image artifacts", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, _ []string) error { + return errors.New("publish command is not implemented yet") + }, + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 531d66f..6581591 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -54,7 +54,13 @@ func NewRootCommand(opts Options) (*cobra.Command, error) { return nil, err } - addCommands(root, newVersionCommand(rt)) + addCommands( + root, + newPlanCommand(rt), + newBuildCommand(rt), + newPublishCommand(rt), + newVersionCommand(rt), + ) return root, nil } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 166d67c..7395e57 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -3,11 +3,16 @@ package cli import ( "bytes" "context" + "os" + "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/meigma/imgcli/internal/providers/incusos" + "github.com/meigma/imgcli/schemas/core" ) type commandResult struct { @@ -92,6 +97,139 @@ func TestInvalidLogSettings(t *testing.T) { } } +func TestBaseCommands(t *testing.T) { + tests := []struct { + name string + command string + placeholder bool + }{ + { + name: "plan", + command: "plan", + placeholder: true, + }, + { + name: "build", + command: "build", + }, + { + name: "publish", + command: "publish", + placeholder: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name+" appears in root help", func(t *testing.T) { + clearIMGCLIEnv(t) + + result := executeCommand(t, Options{}, "--help") + + require.NoError(t, result.err) + assert.Contains(t, result.stdout, tt.command) + assert.Empty(t, result.stderr) + }) + + t.Run(tt.name+" requires config operand", func(t *testing.T) { + clearIMGCLIEnv(t) + + result := executeCommand(t, Options{}, tt.command) + + require.Error(t, result.err) + require.ErrorContains(t, result.err, `accepts 1 arg(s), received 0`) + assert.Empty(t, result.stdout) + assert.Empty(t, result.stderr) + }) + + if !tt.placeholder { + continue + } + + t.Run(tt.name+" returns placeholder error", func(t *testing.T) { + clearIMGCLIEnv(t) + + result := executeCommand(t, Options{}, tt.command, "image.cue") + + require.Error(t, result.err) + require.ErrorContains(t, result.err, tt.command+" command is not implemented yet") + assert.Empty(t, result.stdout) + assert.Empty(t, result.stderr) + }) + } +} + +func TestBuildCommand(t *testing.T) { + t.Run("missing incusos provider fails explicitly", func(t *testing.T) { + clearIMGCLIEnv(t) + configPath := writeImageConfig(t, ` +apiVersion: "imgcli.meigma.io/v0alpha1" +kind: "ImagePlan" +image: name: "test-image" +`) + + result := executeCommand(t, Options{}, "build", configPath) + + require.Error(t, result.err) + require.ErrorContains(t, result.err, "must specify provider incusos") + assert.Empty(t, result.stdout) + assert.Empty(t, result.stderr) + }) + + t.Run("unsupported provider fails explicitly", func(t *testing.T) { + clearIMGCLIEnv(t) + configPath := writeImageConfig(t, ` +apiVersion: "imgcli.meigma.io/v0alpha1" +kind: "ImagePlan" +image: name: "test-image" +talos: {} +`) + + result := executeCommand(t, Options{}, "build", configPath) + + require.Error(t, result.err) + require.ErrorContains(t, result.err, `unsupported provider "talos": only incusos is supported`) + assert.Empty(t, result.stdout) + assert.Empty(t, result.stderr) + }) + + t.Run("prints resolved IncusOS image URL", func(t *testing.T) { + clearIMGCLIEnv(t) + configPath := writeImageConfig(t, ` +apiVersion: "imgcli.meigma.io/v0alpha1" +kind: "ImagePlan" +image: name: "test-image" +incusos: { + defaults: source: channel: "testing" + variants: default: { + source: version: "202604261712" + artifact: { + architecture: "amd64" + format: "raw" + } + } +} +`) + catalog := &testCatalog{ + asset: incusos.ImageAsset{ + URL: "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz", + }, + } + + result := executeCommand(t, Options{IncusOSCatalog: catalog}, "build", configPath) + + require.NoError(t, result.err) + assert.Equal(t, "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz\n", result.stdout) + assert.Empty(t, result.stderr) + require.Len(t, catalog.queries, 1) + assert.Equal(t, incusos.ImageQuery{ + Channel: incusos.ChannelTesting, + Version: incusos.Version("202604261712"), + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + }, catalog.queries[0]) + }) +} + func executeCommand(t *testing.T, opts Options, args ...string) commandResult { t.Helper() @@ -126,3 +264,22 @@ func clearIMGCLIEnv(t *testing.T) { t.Setenv(key, "") } } + +func writeImageConfig(t *testing.T, content string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "image.cue") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + return path +} + +type testCatalog struct { + asset incusos.ImageAsset + queries []incusos.ImageQuery +} + +func (c *testCatalog) ResolveImage(_ context.Context, query incusos.ImageQuery) (incusos.ImageAsset, error) { + c.queries = append(c.queries, query) + return c.asset, nil +} diff --git a/internal/providers/doc.go b/internal/providers/doc.go new file mode 100644 index 0000000..e37c5bd --- /dev/null +++ b/internal/providers/doc.go @@ -0,0 +1,3 @@ +// Package providers defines the shared boundary between imgcli application +// orchestration and provider-specific artifact implementations. +package providers diff --git a/internal/providers/incusos/cdn/client.go b/internal/providers/incusos/cdn/client.go new file mode 100644 index 0000000..5d95109 --- /dev/null +++ b/internal/providers/incusos/cdn/client.go @@ -0,0 +1,282 @@ +package cdn + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/meigma/imgcli/internal/providers/incusos" + "github.com/meigma/imgcli/schemas/core" +) + +const ( + defaultBaseURL = "https://images.linuxcontainers.org/os/" + indexPath = "index.json" + maxIndexBytes = 8 << 20 +) + +var ( + _ incusos.Catalog = (*Client)(nil) + _ incusos.Downloader = (*Client)(nil) +) + +// Option configures a CDN client. +type Option func(*Client) + +// Client resolves and downloads IncusOS images from the Linux Containers CDN. +type Client struct { + baseURL string + httpClient *http.Client +} + +// WithBaseURL configures the CDN base URL containing index.json. +func WithBaseURL(baseURL string) Option { + return func(client *Client) { + client.baseURL = baseURL + } +} + +// WithHTTPClient configures the HTTP client used for CDN requests. +func WithHTTPClient(httpClient *http.Client) Option { + return func(client *Client) { + if httpClient != nil { + client.httpClient = httpClient + } + } +} + +// NewClient constructs a CDN client. +func NewClient(options ...Option) *Client { + client := &Client{ + baseURL: defaultBaseURL, + httpClient: http.DefaultClient, + } + + for _, option := range options { + option(client) + } + + return client +} + +// ResolveImage selects an IncusOS source image asset for the query. +func (c *Client) ResolveImage(ctx context.Context, query incusos.ImageQuery) (incusos.ImageAsset, error) { + normalized, err := normalizeQuery(query) + if err != nil { + return incusos.ImageAsset{}, err + } + + index, err := c.fetchIndex(ctx) + if err != nil { + return incusos.ImageAsset{}, err + } + + update, ok := selectUpdate(index.Updates, normalized.Channel, normalized.Version) + if !ok { + return incusos.ImageAsset{}, fmt.Errorf( + "%w: channel=%q version=%q", + incusos.ErrImageNotFound, + normalized.Channel, + normalized.Version, + ) + } + + fileType, err := cdnFileType(normalized.Type) + if err != nil { + return incusos.ImageAsset{}, err + } + + cdnArch, err := cdnArchitecture(normalized.Architecture) + if err != nil { + return incusos.ImageAsset{}, err + } + + for _, file := range update.Files { + if file.Component != "os" || file.Type != fileType || file.Architecture != cdnArch { + continue + } + + assetURL, err := c.assetURL(update, file) + if err != nil { + return incusos.ImageAsset{}, err + } + + return incusos.ImageAsset{ + Version: update.Version, + Architecture: normalized.Architecture, + Type: normalized.Type, + URL: assetURL, + SHA256: file.SHA256, + Size: file.Size, + }, nil + } + + return incusos.ImageAsset{}, fmt.Errorf( + "%w: channel=%q version=%q architecture=%q type=%q", + incusos.ErrImageNotFound, + normalized.Channel, + update.Version, + normalized.Architecture, + normalized.Type, + ) +} + +// DownloadImage downloads and verifies the provided image asset. +func (c *Client) DownloadImage(_ context.Context, _ incusos.ImageAsset, _ string) (incusos.DownloadedImage, error) { + return incusos.DownloadedImage{}, incusos.ErrNotImplemented +} + +func (c *Client) fetchIndex(ctx context.Context) (catalogIndex, error) { + indexURL, err := url.JoinPath(c.baseURLOrDefault(), indexPath) + if err != nil { + return catalogIndex{}, fmt.Errorf("build incusos catalog index URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, indexURL, nil) + if err != nil { + return catalogIndex{}, fmt.Errorf("create incusos catalog index request: %w", err) + } + + resp, err := c.httpClientOrDefault().Do(req) + if err != nil { + return catalogIndex{}, fmt.Errorf("fetch incusos catalog index: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return catalogIndex{}, fmt.Errorf("fetch incusos catalog index: unexpected HTTP status %s", resp.Status) + } + + var index catalogIndex + decoder := json.NewDecoder(io.LimitReader(resp.Body, maxIndexBytes)) + if err := decoder.Decode(&index); err != nil { + return catalogIndex{}, fmt.Errorf("decode incusos catalog index: %w", err) + } + + return index, nil +} + +func (c *Client) assetURL(update catalogUpdate, file catalogFile) (string, error) { + updatePath := strings.Trim(update.URL, "/") + if updatePath == "" { + updatePath = string(update.Version) + } + + filename := strings.Trim(file.Filename, "/") + assetURL, err := url.JoinPath(c.baseURLOrDefault(), updatePath, filename) + if err != nil { + return "", fmt.Errorf("build incusos asset URL: %w", err) + } + + return assetURL, nil +} + +func (c *Client) baseURLOrDefault() string { + if c.baseURL == "" { + return defaultBaseURL + } + + return c.baseURL +} + +func (c *Client) httpClientOrDefault() *http.Client { + if c.httpClient == nil { + return http.DefaultClient + } + + return c.httpClient +} + +func normalizeQuery(query incusos.ImageQuery) (incusos.ImageQuery, error) { + if query.Channel == "" { + query.Channel = incusos.ChannelStable + } + + if _, err := cdnArchitecture(query.Architecture); err != nil { + return incusos.ImageQuery{}, err + } + + if _, err := cdnFileType(query.Type); err != nil { + return incusos.ImageQuery{}, err + } + + return query, nil +} + +func selectUpdate(updates []catalogUpdate, channel incusos.Channel, version incusos.Version) (catalogUpdate, bool) { + var selected catalogUpdate + for _, update := range updates { + if version != "" && update.Version != version { + continue + } + + if !hasChannel(update.Channels, channel) { + continue + } + + if version != "" { + return update, true + } + + if selected.Version == "" || update.Version > selected.Version { + selected = update + } + } + + if selected.Version == "" { + return catalogUpdate{}, false + } + + return selected, true +} + +func hasChannel(channels []incusos.Channel, channel incusos.Channel) bool { + return slices.Contains(channels, channel) +} + +func cdnArchitecture(architecture core.Architecture) (string, error) { + switch architecture { + case "amd64": + return "x86_64", nil + case "arm64": + return "aarch64", nil + default: + return "", fmt.Errorf("unsupported incusos architecture %q", architecture) + } +} + +func cdnFileType(imageType incusos.ImageType) (string, error) { + switch imageType { + case incusos.ImageTypeRaw: + return "image-raw", nil + case incusos.ImageTypeISO: + return "image-iso", nil + default: + return "", fmt.Errorf("unsupported incusos image type %q", imageType) + } +} + +type catalogIndex struct { + Updates []catalogUpdate `json:"updates"` +} + +type catalogUpdate struct { + Channels []incusos.Channel `json:"channels"` + Files []catalogFile `json:"files"` + Version incusos.Version `json:"version"` + URL string `json:"url"` +} + +type catalogFile struct { + Architecture string `json:"architecture"` + Component string `json:"component"` + Filename string `json:"filename"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Type string `json:"type"` +} diff --git a/internal/providers/incusos/cdn/client_test.go b/internal/providers/incusos/cdn/client_test.go new file mode 100644 index 0000000..4c80b07 --- /dev/null +++ b/internal/providers/incusos/cdn/client_test.go @@ -0,0 +1,232 @@ +package cdn + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/imgcli/internal/providers/incusos" + "github.com/meigma/imgcli/schemas/core" +) + +func TestResolveImage(t *testing.T) { + tests := []struct { + name string + query incusos.ImageQuery + assert func(t *testing.T, baseURL string, asset incusos.ImageAsset, err error) + }{ + { + name: "selects latest stable raw image by default", + query: incusos.ImageQuery{ + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + }, + assert: func(t *testing.T, baseURL string, asset incusos.ImageAsset, err error) { + require.NoError(t, err) + assert.Equal(t, incusos.Version("202604261712"), asset.Version) + assert.Equal(t, core.Architecture("amd64"), asset.Architecture) + assert.Equal(t, incusos.ImageTypeRaw, asset.Type) + assert.Equal(t, baseURL+"/202604261712/x86_64/IncusOS_202604261712.img.gz", asset.URL) + assert.Equal(t, "stable-raw-sha", asset.SHA256) + assert.Equal(t, int64(606774869), asset.Size) + }, + }, + { + name: "selects exact testing iso image", + query: incusos.ImageQuery{ + Channel: incusos.ChannelTesting, + Version: incusos.Version("202604282312"), + Architecture: core.Architecture("arm64"), + Type: incusos.ImageTypeISO, + }, + assert: func(t *testing.T, baseURL string, asset incusos.ImageAsset, err error) { + require.NoError(t, err) + assert.Equal(t, incusos.Version("202604282312"), asset.Version) + assert.Equal(t, core.Architecture("arm64"), asset.Architecture) + assert.Equal(t, incusos.ImageTypeISO, asset.Type) + assert.Equal(t, baseURL+"/202604282312/aarch64/IncusOS_202604282312.iso.gz", asset.URL) + assert.Equal(t, "testing-iso-sha", asset.SHA256) + assert.Equal(t, int64(426796700), asset.Size) + }, + }, + { + name: "requires version to belong to selected channel", + query: incusos.ImageQuery{ + Channel: incusos.ChannelStable, + Version: incusos.Version("202604282312"), + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + }, + assert: func(t *testing.T, _ string, _ incusos.ImageAsset, err error) { + require.ErrorIs(t, err, incusos.ErrImageNotFound) + }, + }, + { + name: "reports missing architecture asset", + query: incusos.ImageQuery{ + Channel: incusos.ChannelStable, + Version: incusos.Version("202604261712"), + Architecture: core.Architecture("arm64"), + Type: incusos.ImageTypeRaw, + }, + assert: func(t *testing.T, _ string, _ incusos.ImageAsset, err error) { + require.ErrorIs(t, err, incusos.ErrImageNotFound) + }, + }, + { + name: "rejects unsupported architecture", + query: incusos.ImageQuery{ + Channel: incusos.ChannelStable, + Architecture: core.Architecture("riscv64"), + Type: incusos.ImageTypeRaw, + }, + assert: func(t *testing.T, _ string, _ incusos.ImageAsset, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported incusos architecture") + }, + }, + } + + server := newCatalogServer(t, http.StatusOK, catalogFixture) + client := NewClient(WithBaseURL(server.URL), WithHTTPClient(server.Client())) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + asset, err := client.ResolveImage(context.Background(), tt.query) + tt.assert(t, server.URL, asset, err) + }) + } +} + +func TestResolveImageHandlesCatalogFetchFailure(t *testing.T) { + server := newCatalogServer(t, http.StatusInternalServerError, "failed") + client := NewClient(WithBaseURL(server.URL), WithHTTPClient(server.Client())) + + asset, err := client.ResolveImage(context.Background(), incusos.ImageQuery{ + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected HTTP status") + assert.Empty(t, asset) +} + +func TestResolveImageHonorsContext(t *testing.T) { + client := NewClient(WithHTTPClient(&http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, context.Canceled + }), + })) + + asset, err := client.ResolveImage(context.Background(), incusos.ImageQuery{ + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + }) + + require.ErrorIs(t, err, context.Canceled) + assert.Empty(t, asset) +} + +func newCatalogServer(t *testing.T, status int, body string) *httptest.Server { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/index.json", r.URL.Path) + w.WriteHeader(status) + _, err := w.Write([]byte(body)) + assert.NoError(t, err) + })) + + t.Cleanup(server.Close) + + return server +} + +type roundTripFunc func(req *http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +const catalogFixture = `{ + "format": "1.0", + "updates": [ + { + "format": "1.0", + "channels": ["testing"], + "version": "202604282312", + "url": "/202604282312", + "files": [ + { + "architecture": "aarch64", + "component": "os", + "filename": "aarch64/IncusOS_202604282312.iso.gz", + "sha256": "testing-iso-sha", + "size": 426796700, + "type": "image-iso" + }, + { + "architecture": "x86_64", + "component": "os", + "filename": "x86_64/IncusOS_202604282312.img.gz", + "sha256": "testing-raw-sha", + "size": 606774869, + "type": "image-raw" + } + ] + }, + { + "format": "1.0", + "channels": ["testing", "stable"], + "version": "202604261712", + "url": "/202604261712", + "files": [ + { + "architecture": "x86_64", + "component": "os", + "filename": "x86_64/IncusOS_202604261712.img.gz", + "sha256": "stable-raw-sha", + "size": 606774869, + "type": "image-raw" + }, + { + "architecture": "x86_64", + "component": "os", + "filename": "x86_64/IncusOS_202604261712.iso.gz", + "sha256": "stable-iso-sha", + "size": 607740891, + "type": "image-iso" + }, + { + "architecture": "x86_64", + "component": "debug", + "filename": "x86_64/debug.raw.gz", + "sha256": "debug-sha", + "size": 5014164, + "type": "application" + } + ] + }, + { + "format": "1.0", + "channels": ["testing", "stable"], + "version": "202604202240", + "url": "/202604202240", + "files": [ + { + "architecture": "x86_64", + "component": "os", + "filename": "x86_64/IncusOS_202604202240.img.gz", + "sha256": "old-stable-raw-sha", + "size": 606774869, + "type": "image-raw" + } + ] + } + ] +}` diff --git a/internal/providers/incusos/cdn/doc.go b/internal/providers/incusos/cdn/doc.go new file mode 100644 index 0000000..6f94ca7 --- /dev/null +++ b/internal/providers/incusos/cdn/doc.go @@ -0,0 +1,2 @@ +// Package cdn adapts the Linux Containers IncusOS CDN to provider ports. +package cdn diff --git a/internal/providers/incusos/doc.go b/internal/providers/incusos/doc.go new file mode 100644 index 0000000..87cc1e9 --- /dev/null +++ b/internal/providers/incusos/doc.go @@ -0,0 +1,2 @@ +// Package incusos implements the IncusOS provider boundary. +package incusos diff --git a/internal/providers/incusos/errors.go b/internal/providers/incusos/errors.go new file mode 100644 index 0000000..94fc833 --- /dev/null +++ b/internal/providers/incusos/errors.go @@ -0,0 +1,9 @@ +package incusos + +import "errors" + +// ErrNotImplemented marks provider operations that are scaffolded but not implemented. +var ErrNotImplemented = errors.New("incusos provider operation is not implemented yet") + +// ErrImageNotFound indicates that no IncusOS catalog entry matched a query. +var ErrImageNotFound = errors.New("incusos image not found") diff --git a/internal/providers/incusos/provider.go b/internal/providers/incusos/provider.go new file mode 100644 index 0000000..c7fd862 --- /dev/null +++ b/internal/providers/incusos/provider.go @@ -0,0 +1,150 @@ +package incusos + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/meigma/imgcli/internal/providers" + "github.com/meigma/imgcli/schemas/core" + incusosschema "github.com/meigma/imgcli/schemas/providers/incusos" +) + +const providerName core.ProviderName = "incusos" + +var _ providers.Provider = (*Provider)(nil) + +// Config is the generated IncusOS provider configuration. +type Config = incusosschema.Config + +// Options wires provider-specific ports used by the IncusOS implementation. +type Options struct { + // Catalog resolves IncusOS release metadata into source image assets. + Catalog Catalog + + // Output receives temporary shallow build output. Nil discards output. + Output io.Writer + + // Downloader retrieves and verifies IncusOS source image assets. + Downloader Downloader + + // SeedBuilder creates IncusOS seed archives from provider configuration. + SeedBuilder SeedBuilder + + // ImageInjector writes seed archives into local IncusOS images. + ImageInjector ImageInjector +} + +// Provider plans and builds IncusOS image artifacts. +type Provider struct { + config Config + options Options +} + +// New constructs an IncusOS provider from generated configuration and ports. +func New(config Config, options Options) *Provider { + return &Provider{ + config: config, + options: options, + } +} + +// Name returns the IncusOS provider name. +func (p *Provider) Name() core.ProviderName { + return providerName +} + +// Plan resolves IncusOS configuration into concrete artifact work. +func (p *Provider) Plan(_ context.Context, _ providers.PlanRequest) (providers.Plan, error) { + return providers.Plan{}, ErrNotImplemented +} + +// Build creates IncusOS artifacts from an already resolved plan. +func (p *Provider) Build(ctx context.Context, _ providers.BuildRequest) (providers.BuildResult, error) { + if p.options.Catalog == nil { + return providers.BuildResult{}, errors.New("incusos catalog is required") + } + + variant, err := singleVariant(p.config) + if err != nil { + return providers.BuildResult{}, err + } + + imageType, err := imageTypeForFormat(variant.Artifact.Format) + if err != nil { + return providers.BuildResult{}, err + } + + source := resolveSource(p.config.Defaults, variant.Source) + asset, err := p.options.Catalog.ResolveImage(ctx, ImageQuery{ + Channel: source.Channel, + Version: source.Version, + Architecture: variant.Artifact.Architecture, + Type: imageType, + }) + if err != nil { + return providers.BuildResult{}, err + } + + if _, err := fmt.Fprintln(p.output(), asset.URL); err != nil { + return providers.BuildResult{}, fmt.Errorf("write incusos image URL: %w", err) + } + + return providers.BuildResult{}, nil +} + +func (p *Provider) output() io.Writer { + if p.options.Output == nil { + return io.Discard + } + + return p.options.Output +} + +func singleVariant(config Config) (incusosschema.Variant, error) { + switch len(config.Variants) { + case 0: + return incusosschema.Variant{}, errors.New("incusos build requires exactly one variant, got 0") + case 1: + for _, variant := range config.Variants { + return variant, nil + } + } + + return incusosschema.Variant{}, fmt.Errorf( + "incusos build requires exactly one variant, got %d", + len(config.Variants), + ) +} + +func resolveSource(defaults *incusosschema.Defaults, variantSource *incusosschema.Source) incusosschema.Source { + var source incusosschema.Source + if defaults != nil && defaults.Source != nil { + source = *defaults.Source + } + if variantSource != nil { + if variantSource.Channel != "" { + source.Channel = variantSource.Channel + } + if variantSource.Version != "" { + source.Version = variantSource.Version + } + } + if source.Channel == "" { + source.Channel = ChannelStable + } + + return source +} + +func imageTypeForFormat(format core.ArtifactFormat) (ImageType, error) { + switch format { + case "raw", "raw.gz": + return ImageTypeRaw, nil + case "iso": + return ImageTypeISO, nil + default: + return "", fmt.Errorf("unsupported incusos artifact format %q", format) + } +} diff --git a/internal/providers/incusos/provider_test.go b/internal/providers/incusos/provider_test.go new file mode 100644 index 0000000..0554fb1 --- /dev/null +++ b/internal/providers/incusos/provider_test.go @@ -0,0 +1,236 @@ +package incusos + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/imgcli/internal/providers" + "github.com/meigma/imgcli/schemas/core" + incusosschema "github.com/meigma/imgcli/schemas/providers/incusos" +) + +func TestProviderName(t *testing.T) { + provider := New(Config{}, Options{}) + + assert.Equal(t, core.ProviderName("incusos"), provider.Name()) +} + +func TestProviderPlanPlaceholderOperation(t *testing.T) { + provider := New(Config{}, Options{}) + + plan, err := provider.Plan(context.Background(), providers.PlanRequest{}) + require.ErrorIs(t, err, ErrNotImplemented) + assert.Empty(t, plan) +} + +func TestProviderBuildResolvesImageURL(t *testing.T) { + tests := []struct { + name string + config Config + wantQuery ImageQuery + }{ + { + name: "uses stable by default", + config: Config{ + Variants: map[core.VariantName]incusosschema.Variant{ + "default": { + Artifact: core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw"), + }, + }, + }, + }, + wantQuery: ImageQuery{ + Channel: ChannelStable, + Architecture: core.Architecture("amd64"), + Type: ImageTypeRaw, + }, + }, + { + name: "uses variant source over defaults", + config: Config{ + Defaults: &incusosschema.Defaults{ + Source: &incusosschema.Source{ + Channel: ChannelStable, + Version: Version("202604202240"), + }, + }, + Variants: map[core.VariantName]incusosschema.Variant{ + "default": { + Source: &incusosschema.Source{ + Channel: ChannelTesting, + Version: Version("202604282312"), + }, + Artifact: core.ArtifactIntent{ + Architecture: core.Architecture("arm64"), + Format: core.ArtifactFormat("iso"), + }, + }, + }, + }, + wantQuery: ImageQuery{ + Channel: ChannelTesting, + Version: Version("202604282312"), + Architecture: core.Architecture("arm64"), + Type: ImageTypeISO, + }, + }, + { + name: "maps raw gzip artifact to raw image", + config: Config{ + Variants: map[core.VariantName]incusosschema.Variant{ + "default": { + Artifact: core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw.gz"), + }, + }, + }, + }, + wantQuery: ImageQuery{ + Channel: ChannelStable, + Architecture: core.Architecture("amd64"), + Type: ImageTypeRaw, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + const assetURL = "https://example.invalid/incusos.img.gz" + + catalog := &recordingCatalog{ + asset: ImageAsset{URL: assetURL}, + } + var output bytes.Buffer + provider := New(tt.config, Options{ + Catalog: catalog, + Output: &output, + }) + + result, err := provider.Build(context.Background(), providers.BuildRequest{}) + + require.NoError(t, err) + assert.Empty(t, result) + require.Len(t, catalog.queries, 1) + assert.Equal(t, tt.wantQuery, catalog.queries[0]) + assert.Equal(t, assetURL+"\n", output.String()) + }) + } +} + +func TestProviderBuildErrors(t *testing.T) { + catalogErr := errors.New("catalog failed") + + tests := []struct { + name string + config Config + catalog *recordingCatalog + wantErr string + wantErrIs error + wantOutput string + }{ + { + name: "missing catalog", + config: configWithVariant(core.ArtifactFormat("raw")), + wantErr: "incusos catalog is required", + }, + { + name: "zero variants", + config: Config{ + Variants: map[core.VariantName]incusosschema.Variant{}, + }, + catalog: &recordingCatalog{}, + wantErr: "incusos build requires exactly one variant, got 0", + }, + { + name: "multiple variants", + config: Config{ + Variants: map[core.VariantName]incusosschema.Variant{ + "default": {}, + "other": {}, + }, + }, + catalog: &recordingCatalog{}, + wantErr: "incusos build requires exactly one variant, got 2", + }, + { + name: "unsupported format", + config: configWithVariant(core.ArtifactFormat("qcow2")), + catalog: &recordingCatalog{}, + wantErr: `unsupported incusos artifact format "qcow2"`, + }, + { + name: "catalog error", + config: Config{ + Variants: map[core.VariantName]incusosschema.Variant{ + "default": { + Artifact: core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw"), + }, + }, + }, + }, + catalog: &recordingCatalog{err: catalogErr}, + wantErrIs: catalogErr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var output bytes.Buffer + options := Options{Output: &output} + if tt.catalog != nil { + options.Catalog = tt.catalog + } + provider := New(tt.config, options) + + result, err := provider.Build(context.Background(), providers.BuildRequest{}) + + require.Error(t, err) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + } + if tt.wantErrIs != nil { + require.ErrorIs(t, err, tt.wantErrIs) + } + assert.Empty(t, result) + assert.Equal(t, tt.wantOutput, output.String()) + }) + } +} + +func configWithVariant(format core.ArtifactFormat) Config { + return Config{ + Variants: map[core.VariantName]incusosschema.Variant{ + "default": { + Artifact: core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: format, + }, + }, + }, + } +} + +type recordingCatalog struct { + asset ImageAsset + err error + queries []ImageQuery +} + +func (c *recordingCatalog) ResolveImage(_ context.Context, query ImageQuery) (ImageAsset, error) { + c.queries = append(c.queries, query) + if c.err != nil { + return ImageAsset{}, c.err + } + + return c.asset, nil +} diff --git a/internal/providers/incusos/types.go b/internal/providers/incusos/types.go new file mode 100644 index 0000000..c99e002 --- /dev/null +++ b/internal/providers/incusos/types.go @@ -0,0 +1,129 @@ +package incusos + +import ( + "context" + + "github.com/meigma/imgcli/schemas/core" + incusosschema "github.com/meigma/imgcli/schemas/providers/incusos" +) + +// Catalog resolves IncusOS image queries into source image assets. +type Catalog interface { + // ResolveImage selects an IncusOS source image asset for the query. + ResolveImage(ctx context.Context, query ImageQuery) (ImageAsset, error) +} + +// Downloader retrieves IncusOS source image assets. +type Downloader interface { + // DownloadImage downloads and verifies the provided image asset. + DownloadImage(ctx context.Context, asset ImageAsset, dst string) (DownloadedImage, error) +} + +// SeedBuilder creates IncusOS seed archives. +type SeedBuilder interface { + // BuildSeed creates the seed archive for a provider configuration. + BuildSeed(ctx context.Context, config Config) (SeedArchive, error) +} + +// ImageInjector writes IncusOS seed archives into local images. +type ImageInjector interface { + // InjectSeed writes a seed archive into a downloaded image. + InjectSeed(ctx context.Context, image DownloadedImage, seed SeedArchive, outputPath string) (CustomizedImage, error) +} + +// Channel is an IncusOS update channel. +type Channel = incusosschema.Channel + +// Version is an IncusOS release version. +type Version = incusosschema.Version + +// ImageType is an IncusOS source image type. +type ImageType string + +const ( + // ChannelStable selects IncusOS releases promoted to stable. + ChannelStable Channel = "stable" + + // ChannelTesting selects IncusOS releases published to testing. + ChannelTesting Channel = "testing" +) + +const ( + // ImageTypeISO selects the IncusOS ISO image. + ImageTypeISO ImageType = "iso" + + // ImageTypeRaw selects the IncusOS raw disk image. + ImageTypeRaw ImageType = "raw" +) + +// ImageQuery describes an IncusOS source image lookup. +type ImageQuery struct { + // Channel selects the IncusOS release channel. + Channel Channel + + // Version selects a specific IncusOS release version. Empty means latest. + Version Version + + // Architecture selects the source image architecture. + Architecture core.Architecture + + // Type selects the source image type. + Type ImageType +} + +// ImageAsset describes an IncusOS source image asset. +type ImageAsset struct { + // Version is the IncusOS release version containing this asset. + Version Version + + // Architecture is the asset architecture. + Architecture core.Architecture + + // Type is the source image type. + Type ImageType + + // URL is the download URL for this asset. + URL string + + // SHA256 is the expected SHA-256 digest in lowercase hex. + SHA256 string + + // Size is the compressed source asset size in bytes. + Size int64 +} + +// DownloadedImage describes a verified local source image. +type DownloadedImage struct { + // Asset is the source asset that was downloaded. + Asset ImageAsset + + // Path is the verified local image path. + Path string + + // SHA256 is the verified SHA-256 digest in lowercase hex. + SHA256 string + + // Size is the local image size in bytes. + Size int64 +} + +// SeedArchive describes an IncusOS seed tar archive. +type SeedArchive struct { + // Data is the tar archive content to inject into the image. + Data []byte +} + +// CustomizedImage describes an image after IncusOS seed injection. +type CustomizedImage struct { + // Source is the verified source image that was customized. + Source DownloadedImage + + // Path is the customized local image path. + Path string + + // Size is the customized image size in bytes. + Size int64 + + // SHA256 is the customized image SHA-256 digest in lowercase hex. + SHA256 string +} diff --git a/internal/providers/provider.go b/internal/providers/provider.go new file mode 100644 index 0000000..995764a --- /dev/null +++ b/internal/providers/provider.go @@ -0,0 +1,112 @@ +package providers + +import ( + "context" + + "github.com/meigma/imgcli/schemas/core" +) + +// Provider plans and builds artifacts for one provider-specific configuration. +type Provider interface { + // Name returns the provider name used in plans and artifact metadata. + Name() core.ProviderName + + // Plan resolves provider-specific configuration into concrete artifact work. + Plan(ctx context.Context, req PlanRequest) (Plan, error) + + // Build creates artifacts from an already resolved provider plan. + Build(ctx context.Context, req BuildRequest) (BuildResult, error) +} + +// PlanRequest carries command-level inputs shared by all providers. +type PlanRequest struct { + // Image is the top-level image identity from the imgcli configuration. + Image core.Image + + // Version is the optional release version supplied by the command or release pipeline. + Version string + + // OutputDir is the root directory where local artifacts should be planned. + OutputDir string +} + +// BuildRequest carries the provider plan and build-time locations. +type BuildRequest struct { + // Plan is the concrete artifact work to execute. + Plan Plan + + // CacheDir is the directory providers may use for reusable downloads or intermediates. + CacheDir string + + // OutputDir is the root directory where local artifacts should be written. + OutputDir string +} + +// Plan is the provider-neutral representation printed by plan and consumed by build. +type Plan struct { + // Provider is the provider responsible for this plan. + Provider core.ProviderName + + // Image is the top-level image identity from the imgcli configuration. + Image core.Image + + // Version is the optional release version supplied by the command or release pipeline. + Version string + + // OutputDir is the root directory where local artifacts should be written. + OutputDir string + + // Artifacts is the concrete artifact work in this plan. + Artifacts []ArtifactPlan +} + +// ArtifactPlan describes one concrete artifact a provider can build. +type ArtifactPlan struct { + // Key is the local handle for the artifact. + Key core.ArtifactKey + + // Variant is the provider variant that produces this artifact. + Variant core.VariantName + + // Architecture is the target architecture for this artifact. + Architecture core.Architecture + + // Format is the artifact file format. + Format core.ArtifactFormat + + // MediaType is the content type expected for the artifact. + MediaType string + + // OutputPath is the planned local artifact path. + OutputPath string + + // Labels are provider or user labels copied to artifact metadata. + Labels map[string]string + + // Annotations are provider or user annotations copied to artifact metadata. + Annotations map[string]string +} + +// BuildResult describes artifacts produced by a provider build. +type BuildResult struct { + // Plan is the provider plan that was executed. + Plan Plan + + // Artifacts are the files produced by the build. + Artifacts []BuiltArtifact +} + +// BuiltArtifact describes one artifact written by a provider. +type BuiltArtifact struct { + // Plan is the planned artifact that produced this file. + Plan ArtifactPlan + + // Path is the final local artifact path. + Path string + + // Size is the artifact size in bytes. + Size int64 + + // SHA256 is the artifact SHA-256 digest in lowercase hex. + SHA256 string +} diff --git a/moon.yml b/moon.yml index e2cec37..92d9d42 100644 --- a/moon.yml +++ b/moon.yml @@ -22,6 +22,8 @@ fileGroups: go-config: - 'go.mod' - 'go.sum' + - 'go.work' + - 'go.work.sum' go-sources: - 'cmd/**/*.go' - 'internal/**/*.go' diff --git a/schemas/embed_test.go b/schemas/embed_test.go index 964a63a..a3e6804 100644 --- a/schemas/embed_test.go +++ b/schemas/embed_test.go @@ -58,3 +58,113 @@ incusos: variants: default: artifact: { t.Fatalf("provider = %q, want %q", provider, "incusos") } } + +func TestIncusOSSourceSchema(t *testing.T) { + ctx := cuecontext.New() + + schema, err := ConfigSchema(ctx) + if err != nil { + t.Fatalf("load config schema: %v", err) + } + + input := ctx.CompileString(` +apiVersion: "imgcli.meigma.io/v0alpha1" +kind: "ImagePlan" +image: { + name: "test-image" +} +incusos: { + defaults: source: channel: "testing" + variants: default: { + source: version: "202604261712" + artifact: { + architecture: "amd64" + format: "raw" + } + } +} +`) + if err := input.Err(); err != nil { + t.Fatalf("compile input: %v", err) + } + + value := schema.Unify(input) + if err := value.Validate(cue.Concrete(false)); err != nil { + t.Fatalf("validate config: %v", err) + } + + channel, err := value.LookupPath(cue.ParsePath("incusos.defaults.source.channel")).String() + if err != nil { + t.Fatalf("lookup channel: %v", err) + } + if channel != "testing" { + t.Fatalf("default source channel = %q, want %q", channel, "testing") + } + + version, err := value.LookupPath(cue.ParsePath("incusos.variants.default.source.version")).String() + if err != nil { + t.Fatalf("lookup version: %v", err) + } + if version != "202604261712" { + t.Fatalf("variant source version = %q, want %q", version, "202604261712") + } +} + +func TestIncusOSSourceValidation(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "invalid channel", + input: ` +apiVersion: "imgcli.meigma.io/v0alpha1" +kind: "ImagePlan" +image: name: "test-image" +incusos: variants: default: { + source: channel: "edge" + artifact: { + architecture: "amd64" + format: "raw" + } +} +`, + }, + { + name: "invalid version", + input: ` +apiVersion: "imgcli.meigma.io/v0alpha1" +kind: "ImagePlan" +image: name: "test-image" +incusos: variants: default: { + source: version: "latest" + artifact: { + architecture: "amd64" + format: "raw" + } +} +`, + }, + } + + ctx := cuecontext.New() + + schema, err := ConfigSchema(ctx) + if err != nil { + t.Fatalf("load config schema: %v", err) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := ctx.CompileString(tt.input) + if err := input.Err(); err != nil { + t.Fatalf("compile input: %v", err) + } + + value := schema.Unify(input) + if err := value.Validate(cue.Concrete(false)); err == nil { + t.Fatal("validate config succeeded, want error") + } + }) + } +} diff --git a/schemas/providers/incusos/cue_types_gen.go b/schemas/providers/incusos/cue_types_gen.go index 5aa5541..c2d6ffb 100644 --- a/schemas/providers/incusos/cue_types_gen.go +++ b/schemas/providers/incusos/cue_types_gen.go @@ -6,12 +6,32 @@ import ( "github.com/meigma/imgcli/schemas/core" ) +type Channel string + +type Version string + +type Source struct { + // Release channel to select from the IncusOS image catalog. + Channel Channel `json:"channel,omitempty"` + + // Specific IncusOS release version. Empty means latest in the selected channel. + Version Version `json:"version,omitempty"` +} + +type Defaults struct { + // Source image selection defaults applied to variants by provider planning. + Source *Source `json:"source,omitempty"` +} + type Variant struct { + // Source image selection for this variant. + Source *Source `json:"source,omitempty"` + Artifact core.ArtifactIntent `json:"artifact"` } type Config struct { - Defaults any/* CUE top */ `json:"defaults,omitempty"` + Defaults *Defaults `json:"defaults,omitempty"` Variants map[core.VariantName]Variant `json:"variants"` } diff --git a/schemas/providers/incusos/schema.cue b/schemas/providers/incusos/schema.cue index b26edbd..b80ce53 100644 --- a/schemas/providers/incusos/schema.cue +++ b/schemas/providers/incusos/schema.cue @@ -2,7 +2,27 @@ package incusos import "github.com/meigma/imgcli/schemas/core" +#Channel: "stable" | "testing" | error("IncusOS source channel must be one of: stable, testing") + +#Version: =~"^[0-9]{12}$" | error("IncusOS source version must be a 12 digit release timestamp") + +#Source: { + // Release channel to select from the IncusOS image catalog. + channel?: #Channel + + // Specific IncusOS release version. Empty means latest in the selected channel. + version?: #Version +} + +#Defaults: { + // Source image selection defaults applied to variants by provider planning. + source?: #Source @go(,optional=nillable) +} + #Variant: { + // Source image selection for this variant. + source?: #Source @go(,optional=nillable) + artifact: core.#ArtifactIntent & { provider: "incusos" os: "incusos" @@ -10,7 +30,7 @@ import "github.com/meigma/imgcli/schemas/core" } #Config: { - defaults?: _ + defaults?: #Defaults @go(,optional=nillable) variants: { [VariantName=core.#VariantName]: #Variant & {