diff --git a/README.md b/README.md index 4a925d3..df0817b 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,25 @@ The CLI also provides resource-based commands for more advanced usage: hypeman [resource] [command] [flags] ``` +## Host Capabilities + +Check what the server build supports on this host before relying on a runtime or feature: + +```bash +# Show server/API version, host OS/arch, runtimes, image platforms, and networking +hypeman capabilities + +# Show capabilities as JSON +hypeman capabilities --format json + +# Show only the runtimes this host supports +hypeman capabilities --transform runtimes +``` + +Each runtime is listed with an `available` flag and its own feature IDs (for example +`snapshots`, `standby`, `fork`, `gpu-passthrough`), so a runtime is only launchable when +its `available` flag is `yes`. + ## Resource Management ### Viewing Server Resources diff --git a/go.mod b/go.mod index 155a747..3850dc4 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/google/go-containerregistry v0.20.7 github.com/gorilla/websocket v1.5.3 github.com/itchyny/json2yaml v0.1.4 - github.com/kernel/hypeman-go v0.24.0 + github.com/kernel/hypeman-go v0.26.1 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 diff --git a/go.sum b/go.sum index 7372aff..09e34bd 100644 --- a/go.sum +++ b/go.sum @@ -1,39 +1,21 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -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.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= -github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 h1:6lhrsTEnloDPXyeZBvSYvQf8u86jbKehZPVDDlkgDl4= github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -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/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= @@ -44,8 +26,6 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -54,12 +34,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/stargz-snapshotter/estargz v0.18.1 h1:cy2/lpgBXDA3cDKSyEfNOFMA/c10O1axL69EU7iirO8= github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q= -github.com/containerd/typeurl/v2 v2.2.0 h1:6NBDbQzr7I5LHgp34xAXYF5DOTQDn05X58lsPEmzLso= -github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= -github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -69,8 +45,6 @@ github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVf github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= -github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.5.3-0.20260325120914-0afb41ce194c+incompatible h1:67C2uqKPtP7yFuTF6o62BpA1JMgUrUTPnsDCbI5kDHo= github.com/docker/docker v28.5.3-0.20260325120914-0afb41ce194c+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= @@ -79,26 +53,12 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -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/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= -github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= -github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -106,12 +66,6 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= -github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= @@ -122,14 +76,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8= github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI= -github.com/kernel/hypeman-go v0.24.0 h1:kWssdYGVmnzVAYJcfbowieCazGxITUebrYil+BEBfag= -github.com/kernel/hypeman-go v0.24.0/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= -github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= -github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kernel/hypeman-go v0.26.1 h1:9SmUwDYL6A+1OzhzkS7auy+/XSef5uiaL9EHftrav/M= +github.com/kernel/hypeman-go v0.26.1/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= @@ -146,14 +96,8 @@ 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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= -github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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= @@ -193,37 +137,19 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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/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/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= -github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= -github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -237,8 +163,6 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= -github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= github.com/urfave/cli-docs/v3 v3.0.0-alpha6 h1:w/l/N0xw1rO/aHRIGXJ0lDwwYFOzilup1qGvIytP3BI= github.com/urfave/cli-docs/v3 v3.0.0-alpha6/go.mod h1:p7Z4lg8FSTrPB9GTaNyTrK3ygffHZcK3w0cU2VE+mzU= github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= @@ -247,12 +171,8 @@ github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CP github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= 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/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= -github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= -go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= @@ -273,16 +193,10 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= 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/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= -golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -296,10 +210,6 @@ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= diff --git a/pkg/cmd/capabilitiescmd.go b/pkg/cmd/capabilitiescmd.go new file mode 100644 index 0000000..f21af24 --- /dev/null +++ b/pkg/cmd/capabilitiescmd.go @@ -0,0 +1,150 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var capabilitiesCmd = cli.Command{ + Name: "capabilities", + Aliases: []string{"capability"}, + Usage: "Show machine-readable host capabilities", + Description: `Report server and API version, host OS/architecture, every runtime available on +this host with its per-runtime feature IDs, the configured default runtime and +whether it is available, guest networking model and host gateway, supported +image platforms, and stable server-level feature IDs. + +Runtime-derived values reflect the actual host (for example, snapshot and +standby support on macOS is gated on the host OS version), so clients can gate +behavior on capabilities without hard-coding hypervisor knowledge. + +Examples: + # Show capabilities (default table format) + hypeman capabilities + + # Show capabilities as JSON + hypeman capabilities --format json + + # Show only the runtimes this host supports + hypeman capabilities --transform runtimes`, + Action: handleCapabilities, + HideHelpCommand: true, +} + +func handleCapabilities(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Capabilities.Get(ctx, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format == "auto" || format == "" { + return showCapabilities(os.Stdout, res) + } + + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "capabilities", obj, format, transform) +} + +func showCapabilities(w io.Writer, data []byte) error { + obj := gjson.ParseBytes(data) + + server := obj.Get("server") + fmt.Fprintln(w, "SERVER") + fmt.Fprintf(w, " Version: %s\n", orDash(server.Get("version").String())) + fmt.Fprintf(w, " API version: %s\n", orDash(server.Get("api_version").String())) + + host := obj.Get("host") + fmt.Fprintln(w) + fmt.Fprintln(w, "HOST") + fmt.Fprintf(w, " OS: %s\n", orDash(host.Get("os").String())) + fmt.Fprintf(w, " Arch: %s\n", orDash(host.Get("arch").String())) + + defaultRuntime := obj.Get("default_runtime") + fmt.Fprintln(w) + fmt.Fprintln(w, "DEFAULT RUNTIME") + fmt.Fprintf(w, " Name: %s\n", orDash(defaultRuntime.Get("name").String())) + fmt.Fprintf(w, " Available: %s\n", yesNo(defaultRuntime.Get("available").Bool())) + + runtimes := obj.Get("runtimes") + if runtimes.IsArray() && len(runtimes.Array()) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "RUNTIMES") + table := NewTableWriter(w, "NAME", "AVAILABLE", "FEATURES") + table.TruncOrder = []int{2} + runtimes.ForEach(func(_, value gjson.Result) bool { + table.AddRow( + value.Get("name").String(), + yesNo(value.Get("available").Bool()), + orDash(joinStrings(value.Get("features"))), + ) + return true + }) + table.Render() + } + + images := obj.Get("images") + fmt.Fprintln(w) + fmt.Fprintln(w, "IMAGES") + fmt.Fprintf(w, " Default platform: %s\n", orDash(images.Get("default_platform").String())) + fmt.Fprintf(w, " Platforms: %s\n", orDash(joinStrings(images.Get("platforms")))) + + network := obj.Get("network") + fmt.Fprintln(w) + fmt.Fprintln(w, "NETWORK") + fmt.Fprintf(w, " Model: %s\n", orDash(network.Get("model").String())) + fmt.Fprintf(w, " Gateway: %s\n", orDash(network.Get("gateway").String())) + fmt.Fprintf(w, " Subnet: %s\n", orDash(network.Get("subnet").String())) + fmt.Fprintf(w, " Guest to guest: %s\n", yesNo(network.Get("guest_to_guest").Bool())) + + fmt.Fprintln(w) + fmt.Fprintln(w, "SERVER FEATURES") + fmt.Fprintf(w, " %s\n", orDash(joinStrings(obj.Get("features")))) + + return nil +} + +func joinStrings(arr gjson.Result) string { + if !arr.IsArray() { + return "" + } + values := make([]string, 0, len(arr.Array())) + arr.ForEach(func(_, value gjson.Result) bool { + values = append(values, value.String()) + return true + }) + return strings.Join(values, ", ") +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} diff --git a/pkg/cmd/capabilitiescmd_test.go b/pkg/cmd/capabilitiescmd_test.go new file mode 100644 index 0000000..7caea94 --- /dev/null +++ b/pkg/cmd/capabilitiescmd_test.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCapabilitiesCmdStructure(t *testing.T) { + assert.Equal(t, "capabilities", capabilitiesCmd.Name) + assert.Contains(t, capabilitiesCmd.Aliases, "capability") + assert.NotNil(t, capabilitiesCmd.Action) +} + +func TestShowCapabilities(t *testing.T) { + payload := []byte(`{ + "default_runtime": {"available": true, "name": "cloud-hypervisor"}, + "features": ["instances", "images", "devices"], + "host": {"arch": "amd64", "os": "linux"}, + "images": {"default_platform": "linux/amd64", "platforms": ["linux/amd64", "linux/arm64"]}, + "network": {"guest_to_guest": false, "model": "bridge", "gateway": "192.168.100.1", "subnet": "192.168.100.0/24"}, + "runtimes": [ + {"available": true, "features": ["snapshots", "standby"], "name": "cloud-hypervisor"}, + {"available": false, "features": [], "name": "qemu"} + ], + "server": {"api_version": "1.2.3", "version": "abc1234"} + }`) + + var buf bytes.Buffer + require.NoError(t, showCapabilities(&buf, payload)) + out := buf.String() + + assert.Contains(t, out, "Version: abc1234") + assert.Contains(t, out, "API version: 1.2.3") + assert.Contains(t, out, "OS: linux") + assert.Contains(t, out, "Arch: amd64") + assert.Contains(t, out, "Name: cloud-hypervisor") + assert.Contains(t, out, "cloud-hypervisor yes") + assert.Contains(t, out, "snapshots, standby") + assert.Contains(t, out, "qemu no") + assert.Contains(t, out, "Default platform: linux/amd64") + assert.Contains(t, out, "Platforms: linux/amd64, linux/arm64") + assert.Contains(t, out, "Model: bridge") + assert.Contains(t, out, "Gateway: 192.168.100.1") + assert.Contains(t, out, "Subnet: 192.168.100.0/24") + assert.Contains(t, out, "Guest to guest: no") + assert.Contains(t, out, "instances, images, devices") +} + +func TestShowCapabilitiesOmitsMissingOptionalFields(t *testing.T) { + payload := []byte(`{ + "default_runtime": {"available": false, "name": "vz"}, + "features": [], + "host": {"arch": "arm64", "os": "darwin"}, + "images": {"default_platform": "linux/arm64", "platforms": ["linux/arm64"]}, + "network": {"guest_to_guest": true, "model": "nat"}, + "runtimes": [], + "server": {"api_version": "1.2.3", "version": "unknown"} + }`) + + var buf bytes.Buffer + require.NoError(t, showCapabilities(&buf, payload)) + out := buf.String() + + assert.Contains(t, out, "Gateway: -") + assert.Contains(t, out, "Subnet: -") + assert.NotContains(t, out, "RUNTIMES") + assert.Contains(t, out, "SERVER FEATURES\n -") +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 14f266b..18284aa 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -94,6 +94,7 @@ func init() { &volumeCmd, &resourcesCmd, &healthCmd, + &capabilitiesCmd, &deviceCmd, &composeCmd, { diff --git a/pkg/cmd/imagecmd.go b/pkg/cmd/imagecmd.go index fe04aa1..7528105 100644 --- a/pkg/cmd/imagecmd.go +++ b/pkg/cmd/imagecmd.go @@ -170,6 +170,9 @@ func handleImageCreateLike(ctx context.Context, cmd *cli.Command, usageLine, out for _, malformed := range malformedTags { fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) } + if credentials, ok := registryCredentialsFromCommand(cmd); ok { + params.Credentials = credentials + } var opts []option.RequestOption if cmd.Root().Bool("debug") { @@ -199,7 +202,7 @@ func handleImageCreateLike(ctx context.Context, cmd *cli.Command, usageLine, out } func imageCreateFlags() []cli.Flag { - return []cli.Flag{ + flags := []cli.Flag{ &cli.StringSliceFlag{ Name: "tag", Usage: "Set image tag key-value pair (KEY=VALUE, can be repeated)", @@ -209,6 +212,7 @@ func imageCreateFlags() []cli.Flag { Usage: `Target platform as os/arch[/variant] (e.g., "linux/amd64"). Defaults to the host platform`, }, } + return append(flags, registryCredentialFlags()...) } func buildImageNewParams(name string, tagSpecs []string, platform string) (hypeman.ImageNewParams, []string) { diff --git a/pkg/cmd/policyflags.go b/pkg/cmd/policyflags.go index d3aa66d..9ce75f8 100644 --- a/pkg/cmd/policyflags.go +++ b/pkg/cmd/policyflags.go @@ -2,8 +2,11 @@ package cmd import ( "fmt" + "time" "github.com/kernel/hypeman-cli/lib/compose" + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/packages/param" "github.com/urfave/cli/v3" ) @@ -85,6 +88,57 @@ func restartPolicyFlags(prefix string) []cli.Flag { } } +func expirationFlags(prefix string) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: prefix + "ttl", + Usage: `Relative lifetime in Go duration format (e.g., "90m"); "0s" disables automatic expiration`, + }, + &cli.StringFlag{ + Name: prefix + "expires-at", + Usage: `Absolute expiration time in RFC3339 format (e.g., "2026-01-02T15:04:05Z")`, + }, + } +} + +type expirationInput struct { + TTL param.Opt[string] + ExpiresAt param.Opt[time.Time] +} + +func parseExpirationInput(cmd *cli.Command, prefix string) (expirationInput, bool, error) { + ttlFlag := prefix + "ttl" + expiresAtFlag := prefix + "expires-at" + + ttlSet := cmd.IsSet(ttlFlag) + expiresAtSet := cmd.IsSet(expiresAtFlag) + // The API rejects requests carrying both fields, so fail before the round trip. + if ttlSet && expiresAtSet { + return expirationInput{}, false, fmt.Errorf("--%sttl and --%sexpires-at are mutually exclusive", prefix, prefix) + } + if !ttlSet && !expiresAtSet { + return expirationInput{}, false, nil + } + + var in expirationInput + if ttlSet { + ttl := cmd.String(ttlFlag) + if _, err := time.ParseDuration(ttl); err != nil { + return expirationInput{}, false, fmt.Errorf(`invalid --%sttl %q: expected a Go duration such as "90m" or "0s"`, prefix, ttl) + } + in.TTL = hypeman.Opt(ttl) + } + if expiresAtSet { + raw := cmd.String(expiresAtFlag) + expiresAt, err := time.Parse(time.RFC3339, raw) + if err != nil { + return expirationInput{}, false, fmt.Errorf(`invalid --%sexpires-at %q: expected an RFC3339 timestamp such as "2026-01-02T15:04:05Z"`, prefix, raw) + } + in.ExpiresAt = hypeman.Opt(expiresAt) + } + return in, true, nil +} + func parseHealthCheckInput(cmd *cli.Command, prefix string) (compose.HealthCheckInput, bool, error) { typeFlag := prefix + "type" intervalFlag := prefix + "interval" diff --git a/pkg/cmd/policyflags_test.go b/pkg/cmd/policyflags_test.go index 38ff36e..4f902c9 100644 --- a/pkg/cmd/policyflags_test.go +++ b/pkg/cmd/policyflags_test.go @@ -3,6 +3,7 @@ package cmd import ( "context" "testing" + "time" "github.com/kernel/hypeman-cli/lib/compose" "github.com/stretchr/testify/assert" @@ -58,6 +59,74 @@ func TestParseHealthCheckInput(t *testing.T) { }) } +func runExpirationParse(t *testing.T, args ...string) (expirationInput, bool, error) { + t.Helper() + var ( + gotIn expirationInput + gotSet bool + gotErr error + ) + cmd := &cli.Command{ + Name: "x", + Flags: expirationFlags(""), + Action: func(_ context.Context, c *cli.Command) error { + gotIn, gotSet, gotErr = parseExpirationInput(c, "") + return nil + }, + } + require.NoError(t, cmd.Run(context.Background(), append([]string{"x"}, args...))) + return gotIn, gotSet, gotErr +} + +func TestParseExpirationInput(t *testing.T) { + t.Run("ttl is passed through verbatim", func(t *testing.T) { + in, set, err := runExpirationParse(t, "--ttl", "90m") + require.NoError(t, err) + require.True(t, set) + assert.Equal(t, "90m", in.TTL.Value) + assert.False(t, in.ExpiresAt.Valid()) + }) + + t.Run("zero ttl disables expiration", func(t *testing.T) { + in, set, err := runExpirationParse(t, "--ttl", "0s") + require.NoError(t, err) + require.True(t, set) + assert.Equal(t, "0s", in.TTL.Value) + }) + + t.Run("expires-at is parsed as RFC3339", func(t *testing.T) { + in, set, err := runExpirationParse(t, "--expires-at", "2026-01-02T15:04:05Z") + require.NoError(t, err) + require.True(t, set) + require.True(t, in.ExpiresAt.Valid()) + assert.Equal(t, time.Date(2026, time.January, 2, 15, 4, 5, 0, time.UTC), in.ExpiresAt.Value) + assert.False(t, in.TTL.Valid()) + }) + + t.Run("ttl and expires-at are mutually exclusive", func(t *testing.T) { + _, _, err := runExpirationParse(t, "--ttl", "1h", "--expires-at", "2026-01-02T15:04:05Z") + require.EqualError(t, err, "--ttl and --expires-at are mutually exclusive") + }) + + t.Run("malformed ttl is rejected", func(t *testing.T) { + _, _, err := runExpirationParse(t, "--ttl", "2 hours") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --ttl") + }) + + t.Run("malformed expires-at is rejected", func(t *testing.T) { + _, _, err := runExpirationParse(t, "--expires-at", "tomorrow") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --expires-at") + }) + + t.Run("no flags reports unset without error", func(t *testing.T) { + _, set, err := runExpirationParse(t) + require.NoError(t, err) + assert.False(t, set) + }) +} + func runRestartParse(t *testing.T, args ...string) (*int64, bool) { t.Helper() var ( diff --git a/pkg/cmd/pull.go b/pkg/cmd/pull.go index dd7c7f6..e1a3f1b 100644 --- a/pkg/cmd/pull.go +++ b/pkg/cmd/pull.go @@ -31,6 +31,9 @@ func handlePull(ctx context.Context, cmd *cli.Command) error { for _, malformed := range malformedTags { fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) } + if credentials, ok := registryCredentialsFromCommand(cmd); ok { + params.Credentials = credentials + } client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) diff --git a/pkg/cmd/pushcmd.go b/pkg/cmd/pushcmd.go index 432c7a1..a1e1036 100644 --- a/pkg/cmd/pushcmd.go +++ b/pkg/cmd/pushcmd.go @@ -18,33 +18,24 @@ import ( ) func pushRemoteFlags() []cli.Flag { - return []cli.Flag{ + flags := []cli.Flag{ &cli.BoolFlag{ Name: "insecure", Usage: "Allow pushing to plain-HTTP registries", }, - &cli.StringFlag{ - Name: "username", - Usage: "Registry username", - }, - &cli.StringFlag{ - Name: "password", - Usage: "Registry password or access token", - }, + } + flags = append(flags, registryCredentialFlags()...) + return append(flags, &cli.BoolFlag{ Name: "password-stdin", Usage: "Read the registry password from stdin", }, - &cli.StringFlag{ - Name: "registry-token", - Usage: "Bearer token for an Authorization header", - }, &cli.BoolFlag{ Name: "detach", Aliases: []string{"d"}, Usage: "Return after queueing the push", }, - } + ) } var pushCreateCmd = cli.Command{ @@ -357,21 +348,7 @@ func buildPushNewParams(image, target string, insecure bool, username, password, params.CreatePushRequest.Insecure = hypeman.Opt(true) } - credentials := hypeman.PushCredentialsParam{} - haveCredentials := false - if username != "" { - credentials.Username = hypeman.Opt(username) - haveCredentials = true - } - if password != "" { - credentials.Password = hypeman.Opt(password) - haveCredentials = true - } - if registryToken != "" { - credentials.RegistryToken = hypeman.Opt(registryToken) - haveCredentials = true - } - if haveCredentials { + if credentials, ok := buildRegistryCredentials(username, password, registryToken); ok { params.CreatePushRequest.Credentials = credentials } diff --git a/pkg/cmd/registrycredentials.go b/pkg/cmd/registrycredentials.go new file mode 100644 index 0000000..dfefce2 --- /dev/null +++ b/pkg/cmd/registrycredentials.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "github.com/kernel/hypeman-go" + "github.com/urfave/cli/v3" +) + +// registryCredentialFlags are the Docker-style registry credentials that the +// server borrows for a single image pull or push request. They are shared by +// every command that talks to a remote registry on the caller's behalf. +func registryCredentialFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Usage: "Registry username", + }, + &cli.StringFlag{ + Name: "password", + Usage: "Registry password or access token", + }, + &cli.StringFlag{ + Name: "registry-token", + Usage: "Bearer token for an Authorization header", + }, + } +} + +func registryCredentialsFromCommand(cmd *cli.Command) (hypeman.PushCredentialsParam, bool) { + return buildRegistryCredentials( + cmd.String("username"), + cmd.String("password"), + cmd.String("registry-token"), + ) +} + +// buildRegistryCredentials reports false when nothing was supplied, so callers +// can leave the field unset and let the server use its own registry +// credentials. +func buildRegistryCredentials(username, password, registryToken string) (hypeman.PushCredentialsParam, bool) { + credentials := hypeman.PushCredentialsParam{} + supplied := false + + if username != "" { + credentials.Username = hypeman.Opt(username) + supplied = true + } + if password != "" { + credentials.Password = hypeman.Opt(password) + supplied = true + } + if registryToken != "" { + credentials.RegistryToken = hypeman.Opt(registryToken) + supplied = true + } + + return credentials, supplied +} diff --git a/pkg/cmd/registrycredentials_test.go b/pkg/cmd/registrycredentials_test.go new file mode 100644 index 0000000..89b0d6e --- /dev/null +++ b/pkg/cmd/registrycredentials_test.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/kernel/hypeman-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestBuildRegistryCredentials(t *testing.T) { + t.Run("reports nothing supplied when all values are empty", func(t *testing.T) { + credentials, supplied := buildRegistryCredentials("", "", "") + + assert.False(t, supplied) + assert.False(t, credentials.Username.Valid()) + assert.False(t, credentials.Password.Valid()) + assert.False(t, credentials.RegistryToken.Valid()) + }) + + t.Run("sets only the values that were supplied", func(t *testing.T) { + credentials, supplied := buildRegistryCredentials("", "", "token") + + require.True(t, supplied) + assert.False(t, credentials.Username.Valid()) + assert.False(t, credentials.Password.Valid()) + require.True(t, credentials.RegistryToken.Valid()) + assert.Equal(t, "token", credentials.RegistryToken.Value) + }) + + t.Run("sets every value", func(t *testing.T) { + credentials, supplied := buildRegistryCredentials("alice", "s3cret", "token") + + require.True(t, supplied) + require.True(t, credentials.Username.Valid()) + assert.Equal(t, "alice", credentials.Username.Value) + require.True(t, credentials.Password.Valid()) + assert.Equal(t, "s3cret", credentials.Password.Value) + require.True(t, credentials.RegistryToken.Valid()) + assert.Equal(t, "token", credentials.RegistryToken.Value) + }) +} + +// TestImageCreateFlagsCarryRegistryCredentials covers the wiring between the +// shared image-create flags and ImageNewParams.Credentials, which lets +// `hypeman pull` and `hypeman image create` pull from a private registry. +func TestImageCreateFlagsCarryRegistryCredentials(t *testing.T) { + var params hypeman.ImageNewParams + + command := &cli.Command{ + Name: "create", + Flags: imageCreateFlags(), + Action: func(_ context.Context, cmd *cli.Command) error { + params, _ = buildImageNewParams(cmd.Args().First(), nil, "") + if credentials, ok := registryCredentialsFromCommand(cmd); ok { + params.Credentials = credentials + } + return nil + }, + } + + require.NoError(t, command.Run(context.Background(), []string{ + "create", "--username", "alice", "--password", "s3cret", "alpine:latest", + })) + + require.Equal(t, "alpine:latest", params.Name) + require.True(t, params.Credentials.Username.Valid()) + assert.Equal(t, "alice", params.Credentials.Username.Value) + require.True(t, params.Credentials.Password.Valid()) + assert.Equal(t, "s3cret", params.Credentials.Password.Value) + assert.False(t, params.Credentials.RegistryToken.Valid()) +} diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index eb87336..38dd2a3 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -46,7 +46,10 @@ Examples: hypeman run --hypervisor qemu-microvm myimage:latest # Run with bandwidth limits - hypeman run --bandwidth-down 1Gbps --bandwidth-up 500Mbps myimage:latest`, + hypeman run --bandwidth-down 1Gbps --bandwidth-up 500Mbps myimage:latest + + # Run with an automatic expiration two hours after creation + hypeman run --ttl 2h myimage:latest`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "name", @@ -193,6 +196,7 @@ Examples: } func init() { + runCmd.Flags = append(runCmd.Flags, expirationFlags("")...) runCmd.Flags = append(runCmd.Flags, healthCheckFlags("health-")...) runCmd.Flags = append(runCmd.Flags, restartPolicyFlags("restart-")...) } @@ -290,6 +294,14 @@ func handleRun(ctx context.Context, cmd *cli.Command) error { if restartInput, ok := parseRestartPolicyInput(cmd, "restart-"); ok { params.RestartPolicy = compose.BuildRestartPolicyParam(restartInput) } + expiration, expirationSet, err := parseExpirationInput(cmd, "") + if err != nil { + return err + } + if expirationSet { + params.Ttl = expiration.TTL + params.ExpiresAt = expiration.ExpiresAt + } // Network configuration networkEnabled := cmd.Bool("network") diff --git a/pkg/cmd/update.go b/pkg/cmd/update.go index 447f438..46bd8a0 100644 --- a/pkg/cmd/update.go +++ b/pkg/cmd/update.go @@ -20,11 +20,13 @@ var updateCmd = cli.Command{ Currently supported: hypeman update auto-standby --enabled --idle-timeout 10m hypeman update egress-credentials --env KEY=VALUE + hypeman update expiration --ttl 2h hypeman update health-check --type http --http-port 8080 hypeman update restart-policy --policy on_failure --max-attempts 5`, Commands: []*cli.Command{ &updateAutoStandbyCmd, &updateEgressCredentialsCmd, + &updateExpirationCmd, &updateHealthCheckCmd, &updateRestartPolicyCmd, }, @@ -72,6 +74,24 @@ var updateEgressCredentialsCmd = cli.Command{ HideHelpCommand: true, } +var updateExpirationCmd = cli.Command{ + Name: "expiration", + Usage: "Update the automatic expiration deadline for an instance", + ArgsUsage: "", + Description: `Set or clear the automatic expiration deadline for an instance. + +TTL values are relative to when the update is committed, and the API rejects +expiration updates made after the current deadline has already passed. + +Examples: + hypeman update expiration my-instance --ttl 2h + hypeman update expiration my-instance --expires-at 2026-01-02T15:04:05Z + hypeman update expiration my-instance --ttl 0s`, + Flags: expirationFlags(""), + Action: handleUpdateExpiration, + HideHelpCommand: true, +} + var updateHealthCheckCmd = cli.Command{ Name: "health-check", Usage: "Update the workload health check policy for an instance", @@ -143,6 +163,60 @@ func handleUpdateAutoStandby(ctx context.Context, cmd *cli.Command) error { return nil } +func handleUpdateExpiration(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID or name required\nUsage: hypeman update expiration [flags]") + } + + expiration, set, err := parseExpirationInput(cmd, "") + if err != nil { + return err + } + if !set { + return fmt.Errorf("one of --ttl or --expires-at is required") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + params := hypeman.InstanceUpdateParams{ + Ttl: expiration.TTL, + ExpiresAt: expiration.ExpiresAt, + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format != "auto" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Instances.Update(ctx, instanceID, params, opts...) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "update expiration", obj, format, transform) + } + + fmt.Fprintf(os.Stderr, "Updating expiration for %s...\n", args[0]) + + instance, err := client.Instances.Update(ctx, instanceID, params, opts...) + if err != nil { + return err + } + fmt.Println(instance.ID) + return nil +} + func handleUpdateHealthCheck(ctx context.Context, cmd *cli.Command) error { args := cmd.Args().Slice() if len(args) < 1 {