diff --git a/go.work.sum b/go.work.sum index e458b6a..0e2cb48 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,5 +1,194 @@ +cloud.google.com/go/accessapproval v1.8.8/go.mod h1:RFwPY9JDKseP4gJrX1BlAVsP5O6kI8NdGlTmaeDefmk= +cloud.google.com/go/accesscontextmanager v1.9.7/go.mod h1:i6e0nd5CPcrh7+YwGq4bKvju5YB9sgoAip+mXU73aMM= +cloud.google.com/go/aiplatform v1.114.0/go.mod h1:W5yMrpIuHG/CSK8iF7XnwIfCJu6dcLRQ0cTqGR5vwwE= +cloud.google.com/go/analytics v0.30.1/go.mod h1:V/FnINU5kMOsttZnKPnXfKi6clJUHTEXUKQjHxcNK8A= +cloud.google.com/go/apigateway v1.7.7/go.mod h1:j1bCmrUK1BzVHpiIyTApxB7cRyhivKzltqLmp6j6i7U= +cloud.google.com/go/apigeeconnect v1.7.7/go.mod h1:ftGK3nca0JePiVLl0A6alaMjKdOc5C+sAkFMyH2RH8U= +cloud.google.com/go/apigeeregistry v0.10.0/go.mod h1:SAlF5OhKvyLDuwWAaFAIVJjrEqKRrGTPkJs+TWNnSqg= +cloud.google.com/go/appengine v1.9.7/go.mod h1:y1XpGVeAhbsNzHida79cHbr3pFRsym0ob8xnC8yphbo= +cloud.google.com/go/area120 v0.9.7/go.mod h1:5nJ0yksmjOMfc4Zpk+okWfJ3A1004FvB82rfia+ZLaY= +cloud.google.com/go/artifactregistry v1.19.0/go.mod h1:UEAPCgHDFC1q+A8nnVxXHPEy9KCVOeavFBF1fEChQvU= +cloud.google.com/go/asset v1.22.0/go.mod h1:q80JP2TeWWzMCazYnrAfDf36aQKf1QiKzzpNLflJwf8= +cloud.google.com/go/assuredworkloads v1.13.0/go.mod h1:o/oHEOnUlribR+uJWTKQo8A5RhSl9K9FNeMOew4TJ3M= +cloud.google.com/go/automl v1.15.0/go.mod h1:U9zOtQb8zVrFNGTuW3BfxeqmLyeleLgT9B12EaXfODg= +cloud.google.com/go/baremetalsolution v1.4.0/go.mod h1:K6C6g4aS8LW95I0fEHZiBsBlh0UxwDLGf+S/vyfXbvg= +cloud.google.com/go/batch v1.14.0/go.mod h1:oeQveyG6NDS/ks2ilOP4LzKRmuIaI7GLe0CkR7WF6pk= +cloud.google.com/go/beyondcorp v1.2.0/go.mod h1:sszcgxpPPBEfLzbI0aYCTg6tT1tyt3CmKav3NZIUcvI= +cloud.google.com/go/bigquery v1.72.0/go.mod h1:GUbRtmeCckOE85endLherHD9RsujY+gS7i++c1CqssQ= +cloud.google.com/go/bigtable v1.41.0/go.mod h1:JlaltP06LEFXaxQdZiarGR9tKsX/II0IkNAKMDrWspI= +cloud.google.com/go/billing v1.21.0/go.mod h1:ZGairB3EVnb3i09E2SxFxo50p5unPaMTuo1jh6jW9js= +cloud.google.com/go/binaryauthorization v1.10.0/go.mod h1:WOuiaQkI4PU/okwrcREjSAr2AUtjQgVe+PlrXKOmKKw= +cloud.google.com/go/certificatemanager v1.9.6/go.mod h1:vWogV874jKZkSRDFCMM3r7wqybv8WXs3XhyNff6o/Zo= +cloud.google.com/go/channel v1.21.0/go.mod h1:8v3TwHtgLmFxTpL2U+e10CLFOQN8u/Vr9RhYcJUS3y8= +cloud.google.com/go/cloudbuild v1.25.0/go.mod h1:lCu+T6IPkobPo2Nw+vCE7wuaAl9HbXLzdPx/tcF+oWo= +cloud.google.com/go/clouddms v1.8.8/go.mod h1:QtCyw+a73dlkDb2q20aTAPvfaTZCepDDi6Gb1AKq0a4= +cloud.google.com/go/cloudtasks v1.13.7/go.mod h1:H0TThOUG+Ml34e2+ZtW6k6nt4i9KuH3nYAJ5mxh7OM4= cloud.google.com/go/compute v1.54.0 h1:4CKmnpO+40z44bKG5bdcKxQ7ocNpRtOc9SCLLUzze1w= +cloud.google.com/go/compute v1.54.0/go.mod h1:RfBj0L1x/pIM84BrzNX2V21oEv16EKRPBiTcBRRH1Ww= +cloud.google.com/go/contactcenterinsights v1.17.4/go.mod h1:kZe6yOnKDfpPz2GphDHynxk/Spx+53UX/pGf+SmWAKM= +cloud.google.com/go/container v1.45.0/go.mod h1:eB6jUfJLjne9VsTDGcH7mnj6JyZK+KOUIA6KZnYE/ds= +cloud.google.com/go/containeranalysis v0.14.2/go.mod h1:FjppROiUtP9cyMegdWdY/TsBSGc6kqh1GjA2NOJXXL8= +cloud.google.com/go/datacatalog v1.26.1/go.mod h1:2Qcq8vsHNxMDgjgadRFmFG47Y+uuIVsyEGUrlrKEdrg= +cloud.google.com/go/dataflow v0.11.1/go.mod h1:3s6y/h5Qz7uuxTmKJKBifkYZ3zs63jS+6VGtSu8Cf7Y= +cloud.google.com/go/dataform v0.12.1/go.mod h1:atGS8ReRjfNDUQib0X/o/7Gi2bqHI2G7/J86LKiGimE= +cloud.google.com/go/datafusion v1.8.7/go.mod h1:4dkFb1la41qCEXh1AzYtFwl842bu2ikTUXyKhjvFCb0= +cloud.google.com/go/datalabeling v0.9.7/go.mod h1:EEUVn+wNn3jl19P2S13FqE1s9LsKzRsPuuMRq2CMsOk= +cloud.google.com/go/dataplex v1.28.0/go.mod h1:VB+xlYJiJ5kreonXsa2cHPj0A3CfPh/mgiHG4JFhbUA= +cloud.google.com/go/dataproc/v2 v2.15.0/go.mod h1:tSdkodShfzrrUNPDVEL6MdH9/mIEvp/Z9s9PBdbsZg8= +cloud.google.com/go/dataqna v0.9.8/go.mod h1:2lHKmGPOqzzuqCc5NI0+Xrd5om4ulxGwPpLB4AnFgpA= +cloud.google.com/go/datastore v1.21.0/go.mod h1:9l+KyAHO+YVVcdBbNQZJu8svF17Nw5sMKuFR0LYf1nY= +cloud.google.com/go/datastream v1.15.1/go.mod h1:aV1Grr9LFon0YvqryE5/gF1XAhcau2uxN2OvQJPpqRw= +cloud.google.com/go/deploy v1.27.3/go.mod h1:7LFIYYTSSdljYRqY3n+JSmIFdD4lv6aMD5xg0crB5iw= +cloud.google.com/go/dialogflow v1.74.0/go.mod h1:jlKHmd3/KdvWWhGZjoCnWQAQNOMHOhDK6DQ430p3T1I= +cloud.google.com/go/dlp v1.28.0/go.mod h1:C3od1fIK8lf7Kr62aU1Uh0z4OL5Z8s3do3znAiEupAw= +cloud.google.com/go/documentai v1.39.0/go.mod h1:KmlLO93F7GRU8dENXRxvt+7V8o7eCG6Y6WDitKbcYJs= +cloud.google.com/go/domains v0.10.7/go.mod h1:T3WG/QUAO/52z4tUPooKS8AY7yXaFxPYn1V3F0/JbNQ= +cloud.google.com/go/edgecontainer v1.4.4/go.mod h1:yyNVHsCKtsX/0mqFdbljQw0Uo660q2dlMPaiqYiC2Tg= +cloud.google.com/go/errorreporting v0.4.0/go.mod h1:dZGEhqzdHZSRxxWLVjC3Ue5CVaROzvP58D9rU6zbBfw= +cloud.google.com/go/essentialcontacts v1.7.7/go.mod h1:ytycWAEn/aKUMRKQPMVgMrAtphEMgjbzL8vFwM3tqXs= +cloud.google.com/go/eventarc v1.18.0/go.mod h1:/6SDoqh5+9QNUqCX4/oQcJVK16fG/snHBSXu7lrJtO8= +cloud.google.com/go/filestore v1.10.3/go.mod h1:94ZGyLTx9j+aWKozPQ6Wbq1DuImie/L/HIdGMshtwac= +cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= +cloud.google.com/go/functions v1.19.7/go.mod h1:xbcKfS7GoIcaXr2FSwmtn9NXal1JR4TV6iYZlgXffwA= +cloud.google.com/go/gkebackup v1.8.1/go.mod h1:GAaAl+O5D9uISH5MnClUop2esQW4pDa2qe/95A4l7YQ= +cloud.google.com/go/gkeconnect v0.12.5/go.mod h1:wMD2RXcsAWlkREZWJDVeDV70PYka1iEb9stFmgpw+5o= +cloud.google.com/go/gkehub v0.16.0/go.mod h1:ADp27Ucor8v81wY+x/5pOxTorxkPj/xswH3AUpN62GU= +cloud.google.com/go/gkemulticloud v1.6.0/go.mod h1:bGpd4o/Z5Z/XFlaojkgdVisHRwb+fLJvUPzsmV0I9ok= +cloud.google.com/go/gsuiteaddons v1.7.8/go.mod h1:DBKNHH4YXAdd/rd6zVvtOGAJNGo0ekOh+nIjTUDEJ5U= +cloud.google.com/go/iap v1.11.3/go.mod h1:+gXO0ClH62k2LVlfhHzrpiHQNyINlEVmGAE3+DB4ShU= +cloud.google.com/go/ids v1.5.7/go.mod h1:N3ZQOIgIBwwOu2tzyhmh3JDT+kt8PcoKkn2BRT9Qe4A= +cloud.google.com/go/iot v1.8.7/go.mod h1:HvVcypV8LPv1yTXSLCNK+YCtqGHhq+p0F3BXETfpN+U= +cloud.google.com/go/language v1.14.6/go.mod h1:7y3J9OexQsfkWNGCxhT+7lb64pa60e12ZCoWDOHxJ1M= +cloud.google.com/go/lifesciences v0.10.7/go.mod h1:v3AbTki9iWttEls/Wf4ag3EqeLRHofploOcpsLnu7iY= +cloud.google.com/go/managedidentities v1.7.7/go.mod h1:nwNlMxtBo2YJMvsKXRtAD1bL41qiCI9npS7cbqrsJUs= +cloud.google.com/go/maps v1.26.0/go.mod h1:+auempdONAP8emtm48aCfNo1ZC+3CJniRA1h8J4u7bY= +cloud.google.com/go/mediatranslation v0.9.7/go.mod h1:mz3v6PR7+Fd/1bYrRxNFGnd+p4wqdc/fyutqC5QHctw= +cloud.google.com/go/memcache v1.11.7/go.mod h1:AU1jYlUqCihxapcJ1GGMtlMWDVhzjbfUWBXqsXa4rBg= +cloud.google.com/go/metastore v1.14.8/go.mod h1:h1XI2LpD4ohJhQYn9TwXqKb5sVt6KSo47ft96SiFF1s= +cloud.google.com/go/networkconnectivity v1.20.0/go.mod h1:9MzGwD4ljiq+Z2Pg3ue27OEewCuHz7IUfw1fITrIdSw= +cloud.google.com/go/networkmanagement v1.21.0/go.mod h1:clG/5Yt0wQ57qSH6Yh7oehQYlobHw3F6nb3Pn4ig5hU= +cloud.google.com/go/networksecurity v0.11.0/go.mod h1:JLgDsg4tOyJ3eMO8lypjqMftbfd60SJ+P7T+DUmWBsM= +cloud.google.com/go/notebooks v1.12.7/go.mod h1:uR9pxAkKmlNloibMr9Q1t8WhIu4P2JeqJs7c064/0Mo= +cloud.google.com/go/optimization v1.7.7/go.mod h1:OY2IAlX23o52qwMAZ0w65wibKuV12a4x6IHDTCq6kcU= +cloud.google.com/go/orchestration v1.11.10/go.mod h1:tz7m1s4wNEvhNNIM3JOMH0lYxBssu9+7si5MCPw/4/0= +cloud.google.com/go/orgpolicy v1.15.1/go.mod h1:bpvi9YIyU7wCW9WiXL/ZKT7pd2Ovegyr2xENIeRX5q0= +cloud.google.com/go/osconfig v1.15.1/go.mod h1:NegylQQl0+5m+I+4Ey/g3HGeQxKkncQ1q+Il4DZ8PME= +cloud.google.com/go/oslogin v1.14.7/go.mod h1:NB6NqBHfDMwznePdBVX+ILllc1oPCdNSGp5u/WIyndY= +cloud.google.com/go/phishingprotection v0.9.7/go.mod h1:JTI4HNGyAbWolBoNOoCyCF0e3cqPNrYnlievHU49EwE= +cloud.google.com/go/policytroubleshooter v1.11.7/go.mod h1:JP/aQ+bUkt4Gz6lQXBi/+A/6nyNRZ0Pvxui5Xl9ieyk= +cloud.google.com/go/privatecatalog v0.10.8/go.mod h1:BkLHi+rtAGYBt5DocXLytHhF0n6F03Tegxgty40Y7aA= +cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= +cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= +cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI= +cloud.google.com/go/recaptchaenterprise/v2 v2.21.0/go.mod h1:HxQYqZC2/zl2CvKN7jJEv71vEdDi1GMGNUiZxnpiuVI= +cloud.google.com/go/recommendationengine v0.9.7/go.mod h1:snZ/FL147u86Jqpv1j95R+CyU5NvL/UzYiyDo6UByTM= +cloud.google.com/go/recommender v1.13.6/go.mod h1:y5/5womtdOaIM3xx+76vbsiA+8EBTIVfWnxHDFHBGJM= +cloud.google.com/go/redis v1.18.3/go.mod h1:x8HtXZbvMBDNT6hMHaQ022Pos5d7SP7YsUH8fCJ2Wm4= +cloud.google.com/go/resourcemanager v1.10.7/go.mod h1:rScGkr6j2eFwxAjctvOP/8sqnEpDbQ9r5CKwKfomqjs= +cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw= +cloud.google.com/go/retail v1.25.1/go.mod h1:J75G8pd+DH0SHueL9IJw7Y5d2VhTsjFsk+F1t9f8jXc= +cloud.google.com/go/run v1.15.0/go.mod h1:rgFHMdAopLl++57vzeqA+a1o2x0/ILZnEacRD6nC0EA= +cloud.google.com/go/scheduler v1.11.8/go.mod h1:bNKU7/f04eoM6iKQpwVLvFNBgGyJNS87RiFN73mIPik= +cloud.google.com/go/secretmanager v1.16.0/go.mod h1://C/e4I8D26SDTz1f3TQcddhcmiC3rMEl0S1Cakvs3Q= +cloud.google.com/go/security v1.19.2/go.mod h1:KXmf64mnOsLVKe8mk/bZpU1Rsvxqc0Ej0A6tgCeN93w= +cloud.google.com/go/securitycenter v1.38.1/go.mod h1:Ge2D/SlG2lP1FrQD7wXHy8qyeloRenvKXeB4e7zO6z0= +cloud.google.com/go/servicedirectory v1.12.7/go.mod h1:gOtN+qbuCMH6tj2dqlDY3qQL7w3V0+nkWaZElnJK8Ps= +cloud.google.com/go/shell v1.8.7/go.mod h1:OTke7qc3laNEW5Jr5OV9VR3IwU5x5VqGOE6705zFex4= +cloud.google.com/go/spanner v1.87.0/go.mod h1:tcj735Y2aqphB6/l+X5MmwG4NnV+X1NJIbFSZGaHYXw= +cloud.google.com/go/speech v1.29.0/go.mod h1:wtUmIS/h0ZYU6cPA9klcyST3f6i2FdnvNDqENjrRDds= +cloud.google.com/go/storagetransfer v1.13.1/go.mod h1:S858w5l383ffkdqAqrAA+BC7KlhCqeNieK3sFf5Bj4Y= +cloud.google.com/go/talent v1.8.4/go.mod h1:3yukBXUTVFNyKcJpUExW/k5gqEy8qW6OCNj7WdN0MWo= +cloud.google.com/go/texttospeech v1.16.0/go.mod h1:AeSkoH3ziPvapsuyI07TWY4oGxluAjntX+pF4PJ2jy0= +cloud.google.com/go/tpu v1.8.4/go.mod h1:ul0cyWSHr6jHGZYElZe6HvQn35VY93RAlwpDiSBRnPA= +cloud.google.com/go/translate v1.12.7/go.mod h1:wwJp14NZyWvcrFANhIXutXj0pOBkYciBHwSlUOykcjI= +cloud.google.com/go/video v1.27.1/go.mod h1:xzfAC77B4vtnbi/TT3UUxEjCa/+Ehy5EA8w470ytOig= +cloud.google.com/go/videointelligence v1.12.7/go.mod h1:XAk5hCMY+GihxJ55jNoMdwdXSNZnCl3wGs2+94gK7MA= +cloud.google.com/go/vision/v2 v2.9.6/go.mod h1:lJC+vP15D5znJvHQYjEoTKnpToX1L93BUlvBmzM0gyg= +cloud.google.com/go/vmmigration v1.10.0/go.mod h1:LDztCWEb+RwS1bPg4Xzt0fcJS9kVrFxa3ejhH7OW9vg= +cloud.google.com/go/vmwareengine v1.3.6/go.mod h1:ps0rb+Skgpt9ppHYC0o5DqtJ5ld2FyS8sAqtbHH8t9s= +cloud.google.com/go/vpcaccess v1.8.7/go.mod h1:9RYw5bVvk4Z51Rc8vwXT63yjEiMD/l7XyEaDyrNHgmk= +cloud.google.com/go/webrisk v1.11.2/go.mod h1:yH44GeXz5iz4HFsIlGeoVvnjwnmfbni7Lwj1SelV4f0= +cloud.google.com/go/websecurityscanner v1.7.7/go.mod h1:ng/PzARaus3Bj4Os4LpUnyYHsbtJky1HbBDmz148v1o= +cloud.google.com/go/workflows v1.14.3/go.mod h1:CC9+YdVI2Kvp0L58WajHpEfKJxhrtRh3uQ0SYWcmAk4= +filippo.io/nistec v0.0.4/go.mod h1:PK/lw8I1gQT4hUML4QGaqljwdDaFcMyFKSXN7kjrtKI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= +github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/cristalhq/acmd v0.12.0/go.mod h1:LG5oa43pE/BbxtfMoImHCQN++0Su7dzipdgBjMCBVDQ= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= +github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/lyft/protoc-gen-star/v2 v2.0.4-0.20230330145011-496ad1ac90a4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= +github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mgechev/dots v1.0.0/go.mod h1:rykuMydC9t3wfkM+ccYH3U3ss03vZGg6h3hmOznXLH0= +github.com/mitchellh/cli v1.1.5/go.mod h1:v8+iFts2sPIKUV1ltktPXMCC8fumSKFItNcD2cLtRR4= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/mozilla/tls-observatory v0.0.0-20250923143331-eef96233227e/go.mod h1:FUqVoUPHSEdDR0MnFM3Dh8AU0pZHLXUD127SAJGER/s= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/openai/openai-go/v3 v3.26.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50= +github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/quicktemplate v1.8.0/go.mod h1:qIqW8/igXt8fdrUln5kOSb+KWMaJ4Y8QUsfd1k6L2jM= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20260203192932-546029d2fa20/go.mod h1:Tej9lWiwVvQJP+b43pjJIsr/3mZycXWCIyoiXmbFf40= +google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8= +gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= diff --git a/tools/labctl/cmd/labctl/main_test.go b/tools/labctl/cmd/labctl/main_test.go index 5301124..14b31c1 100644 --- a/tools/labctl/cmd/labctl/main_test.go +++ b/tools/labctl/cmd/labctl/main_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "fmt" "net/http" "net/http/httptest" @@ -9,10 +10,13 @@ import ( "testing" "github.com/rogpeppe/go-internal/testscript" + "github.com/ulikunitz/xz" "github.com/gilmanlab/platform/tools/labctl/internal/adapters/githubcontents" ) +const defaultTalosSchematicID = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" + func TestMain(m *testing.M) { testscript.Main(m, map[string]func(){ "labctl": func() { @@ -29,27 +33,89 @@ func TestCLI(t *testing.T) { } func setupTestScript(env *testscript.Env) error { + talosArchive, err := xzBytes([]byte("talos-raw-image")) + if err != nil { + return err + } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/repos/GilmanLab/secrets/contents/network/keycloak.sops.yaml" { + switch r.URL.Path { + case "/repos/GilmanLab/secrets/contents/network/keycloak.sops.yaml": + handleSecretFixture(env, w, r) + case "/image/" + defaultTalosSchematicID + "/v1.13.0/nocloud-amd64.raw.xz": + _, _ = w.Write(talosArchive) + default: http.Error(w, fmt.Sprintf("unexpected path %s", r.URL.Path), http.StatusNotFound) - - return - } - if r.URL.Query().Get("ref") != "feature" { - http.Error(w, fmt.Sprintf("unexpected ref %s", r.URL.Query().Get("ref")), http.StatusBadRequest) - - return } - if r.Header.Get("Authorization") != "Bearer ghs_test" { - http.Error(w, "unexpected authorization header", http.StatusUnauthorized) - - return - } - - http.ServeFile(w, r, filepath.Join(env.WorkDir, "secrets/network/keycloak.sops.yaml")) })) env.Defer(server.Close) env.Setenv(githubcontents.EnvAPIBaseURL, server.URL) + if err := os.WriteFile( + filepath.Join(env.WorkDir, "controlplane.yaml"), + []byte("machine:\n type: controlplane\n"), + 0o600, + ); err != nil { + return err + } + if err := os.WriteFile( + filepath.Join(env.WorkDir, "network-config.yaml"), + []byte("version: 1\n"), + 0o600, + ); err != nil { + return err + } + validConfig := fmt.Appendf(nil, `name: talos-test +source: + factoryURL: %s + version: v1.13.0 +config: + userData: + path: controlplane.yaml + metaData: + localHostname: bootstrap-controlplane-1 + networkConfig: + path: network-config.yaml +output: + dir: .state/images + format: img + bootArtifactName: talos-boot.img + configArtifactName: talos-cidata.img +`, server.URL) + if err := os.WriteFile(filepath.Join(env.WorkDir, "talos-valid.yaml"), validConfig, 0o600); err != nil { + return err + } + return nil } + +func handleSecretFixture(env *testscript.Env, w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("ref") != "feature" { + http.Error(w, fmt.Sprintf("unexpected ref %s", r.URL.Query().Get("ref")), http.StatusBadRequest) + + return + } + if r.Header.Get("Authorization") != "Bearer ghs_test" { + http.Error(w, "unexpected authorization header", http.StatusUnauthorized) + + return + } + + http.ServeFile(w, r, filepath.Join(env.WorkDir, "secrets/network/keycloak.sops.yaml")) +} + +func xzBytes(data []byte) ([]byte, error) { + var buffer bytes.Buffer + writer, err := xz.NewWriter(&buffer) + if err != nil { + return nil, err + } + _, err = writer.Write(data) + if err != nil { + return nil, err + } + if err := writer.Close(); err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} diff --git a/tools/labctl/cmd/labctl/testdata/script/talos_image_build.txtar b/tools/labctl/cmd/labctl/testdata/script/talos_image_build.txtar new file mode 100644 index 0000000..4e4c17a --- /dev/null +++ b/tools/labctl/cmd/labctl/testdata/script/talos_image_build.txtar @@ -0,0 +1,57 @@ +exec labctl bootstrap talos image build --help +stdout 'Build Talos boot and NoCloud images' +stdout '--json' +! stderr . + +! exec labctl bootstrap talos image build invalid.yaml +stderr 'validate invalid.yaml' +stderr 'format must be img' +! stdout . + +stdin invalid.yaml +! exec labctl bootstrap talos image build - +stderr 'validate stdin.yaml' +stderr 'format must be img' +! stdout . + +stdin talos-valid.yaml +exec labctl bootstrap talos image build - +stdout 'talos-boot.img' +stdout 'talos-cidata.img' +! stderr . + +exec labctl bootstrap talos image build talos-valid.yaml +stdout 'talos-boot.img' +stdout 'talos-cidata.img' +exists .state/images/talos-boot.img +exists .state/images/talos-cidata.img +exists .state/downloads/talos/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/v1.13.0/nocloud-amd64.raw.xz +! stderr . + +exec labctl bootstrap talos image build --json talos-valid.yaml +stdout '"name":"talos-test"' +stdout '"bootArtifactPath":' +stdout '"configArtifactPath":' +stdout '"sourceVersion":"v1.13.0"' +stdout '"sourceURL":' +stdout 'nocloud-amd64.raw.xz' +stdout '"sourceSchematicID":"376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba"' +stdout '"platform":"nocloud"' +stdout '"arch":"amd64"' +stdout '"format":"img"' +! stderr . + +-- invalid.yaml -- +name: talos-test +source: + version: v1.13.0 +config: + userData: + path: controlplane.yaml + metaData: + localHostname: bootstrap-controlplane-1 +output: + dir: .state/images + format: iso + bootArtifactName: talos-boot.img + configArtifactName: talos-cidata.img diff --git a/tools/labctl/go.mod b/tools/labctl/go.mod index 8e12718..6febd1c 100644 --- a/tools/labctl/go.mod +++ b/tools/labctl/go.mod @@ -9,12 +9,14 @@ require ( github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/config v1.32.17 github.com/aws/aws-sdk-go-v2/service/lambda v1.90.1 + github.com/diskfs/go-diskfs v1.9.1 github.com/getsops/sops/v3 v3.12.2 - github.com/gilmanlab/platform/schemas/lab v0.0.0-20260429225352-54467f00b6ff + github.com/gilmanlab/platform/schemas/lab v0.2.1 github.com/rogpeppe/go-internal v1.14.1 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + github.com/ulikunitz/xz v0.5.15 go.yaml.in/yaml/v3 v3.0.4 go.yaml.in/yaml/v4 v4.0.0-rc.4 ) @@ -77,6 +79,7 @@ require ( github.com/alfatraining/structtag v1.0.0 // indirect github.com/alingse/asasalint v0.0.11 // indirect github.com/alingse/nilnesserr v0.2.0 // indirect + github.com/anchore/go-lzo v0.1.0 // indirect github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect github.com/ashanbrown/makezero/v2 v2.1.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect @@ -127,7 +130,9 @@ require ( github.com/dave/dst v0.27.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect + github.com/djherbis/times v1.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/ettle/strcase v0.2.0 // indirect @@ -204,6 +209,7 @@ require ( github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect github.com/kisielk/errcheck v1.10.0 // indirect github.com/kkHAIKE/contextcheck v1.1.6 // indirect + github.com/klauspost/compress v1.17.4 // indirect github.com/kulti/thelper v0.7.1 // indirect github.com/kunwardeep/paralleltest v1.0.15 // indirect github.com/kylelemons/godebug v1.1.0 // indirect @@ -239,8 +245,10 @@ require ( github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.23.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pierrec/lz4/v4 v4.1.17 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/xattr v0.4.12 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.12.1 // indirect diff --git a/tools/labctl/go.sum b/tools/labctl/go.sum index 561145e..ba60625 100644 --- a/tools/labctl/go.sum +++ b/tools/labctl/go.sum @@ -163,6 +163,8 @@ github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQ github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= +github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= +github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= github.com/ashanbrown/forbidigo/v2 v2.3.0 h1:OZZDOchCgsX5gvToVtEBoV2UWbFfI6RKQTir2UZzSxo= github.com/ashanbrown/forbidigo/v2 v2.3.0/go.mod h1:5p6VmsG5/1xx3E785W9fouMxIOkvY2rRV9nMdWadd6c= github.com/ashanbrown/makezero/v2 v2.1.0 h1:snuKYMbqosNokUKm+R6/+vOPs8yVAi46La7Ck6QYSaE= @@ -292,8 +294,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= +github.com/diskfs/go-diskfs v1.9.1 h1:g/UCTC5jZFomhtH4DyF9fG1eRHGgDIjSd1hSjEErXn0= +github.com/diskfs/go-diskfs v1.9.1/go.mod h1:rW9+4MPN1tbMpQqRZlcM3YQsh3Ucc+Q1k1iIqzzmZcg= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= @@ -302,6 +308,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= 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/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY= +github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= github.com/emicklei/proto v1.14.3 h1:zEhlzNkpP8kN6utonKMzlPfIvy82t5Kb9mufaJxSe1Q= github.com/emicklei/proto v1.14.3/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -339,8 +347,8 @@ github.com/getsops/sops/v3 v3.12.2 h1:4ctEFDNpAAubW8EMICytX8+BFDBSFJkrKvQ9ahSs0a github.com/getsops/sops/v3 v3.12.2/go.mod h1:BACmHQl0J8nPNXBDSJKRT5oUdZx36CkbohGDj9+bD9M= github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0= github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= -github.com/gilmanlab/platform/schemas/lab v0.0.0-20260429225352-54467f00b6ff h1:oOV0AZQdilKCTtvW4PwhOXzWUeUnamxHpw05alRYheg= -github.com/gilmanlab/platform/schemas/lab v0.0.0-20260429225352-54467f00b6ff/go.mod h1:V6xQvLWFqzyxZ2Ku2m7C46Tv6Pa65xLlkF3OF2uXZME= +github.com/gilmanlab/platform/schemas/lab v0.2.1 h1:q98tlzsP6Ai7hLrUn3dQ7V8GfhR1NYnLJwkLLAFLBsA= +github.com/gilmanlab/platform/schemas/lab v0.2.1/go.mod h1:V6xQvLWFqzyxZ2Ku2m7C46Tv6Pa65xLlkF3OF2uXZME= github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -588,6 +596,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -711,12 +721,16 @@ github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT9 github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= 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/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc= +github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= +github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -855,6 +869,8 @@ github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVF github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo= github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= @@ -1110,7 +1126,9 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/tools/labctl/internal/adapters/nocloudcidata/builder.go b/tools/labctl/internal/adapters/nocloudcidata/builder.go new file mode 100644 index 0000000..895f07a --- /dev/null +++ b/tools/labctl/internal/adapters/nocloudcidata/builder.go @@ -0,0 +1,76 @@ +package nocloudcidata + +import ( + "fmt" + "os" + + diskfs "github.com/diskfs/go-diskfs" + "github.com/diskfs/go-diskfs/disk" + "github.com/diskfs/go-diskfs/filesystem" + + "github.com/gilmanlab/platform/tools/labctl/internal/app/talosimage" +) + +const ( + cidataSizeBytes = 16 * 1024 * 1024 + volumeLabel = "CIDATA" +) + +// Builder writes NoCloud cidata disk images. +type Builder struct{} + +// New constructs a Builder. +func New() Builder { + return Builder{} +} + +// Build writes a FAT32 NoCloud cidata image to path. +func (Builder) Build(path string, payload talosimage.ConfigDiskPayload) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove existing cidata image %q: %w", path, err) + } + + diskImage, err := diskfs.Create(path, cidataSizeBytes, diskfs.SectorSize512) + if err != nil { + return fmt.Errorf("create cidata image %q: %w", path, err) + } + + cidata, err := diskImage.CreateFilesystem(disk.FilesystemSpec{ + Partition: 0, + FSType: filesystem.TypeFat32, + VolumeLabel: volumeLabel, + Reproducible: true, + }) + if err != nil { + return fmt.Errorf("create cidata filesystem %q: %w", path, err) + } + defer cidata.Close() + + if err := writeFile(cidata, "/user-data", payload.UserData); err != nil { + return err + } + if err := writeFile(cidata, "/meta-data", payload.MetaData); err != nil { + return err + } + if len(payload.NetworkConfig) > 0 { + if err := writeFile(cidata, "/network-config", payload.NetworkConfig); err != nil { + return err + } + } + + return nil +} + +func writeFile(cidata filesystem.FileSystem, path string, data []byte) error { + file, err := cidata.OpenFile(path, os.O_CREATE|os.O_RDWR) + if err != nil { + return fmt.Errorf("create cidata file %q: %w", path, err) + } + defer file.Close() + + if _, err := file.Write(data); err != nil { + return fmt.Errorf("write cidata file %q: %w", path, err) + } + + return nil +} diff --git a/tools/labctl/internal/adapters/nocloudcidata/builder_test.go b/tools/labctl/internal/adapters/nocloudcidata/builder_test.go new file mode 100644 index 0000000..f2fb4ce --- /dev/null +++ b/tools/labctl/internal/adapters/nocloudcidata/builder_test.go @@ -0,0 +1,46 @@ +package nocloudcidata_test + +import ( + "io/fs" + "strings" + "testing" + + diskfs "github.com/diskfs/go-diskfs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gilmanlab/platform/tools/labctl/internal/adapters/nocloudcidata" + "github.com/gilmanlab/platform/tools/labctl/internal/app/talosimage" +) + +func TestBuilderWritesNoCloudCIDATAImage(t *testing.T) { + path := t.TempDir() + "/cidata.img" + payload := talosimage.ConfigDiskPayload{ + UserData: []byte("machine:\n type: controlplane\n"), + MetaData: []byte("instance-id: test\nlocal-hostname: bootstrap\n"), + NetworkConfig: []byte("version: 1\n"), + } + + err := nocloudcidata.New().Build(path, payload) + + require.NoError(t, err) + diskImage, err := diskfs.Open(path, diskfs.WithOpenMode(diskfs.ReadOnly)) + require.NoError(t, err) + cidata, err := diskImage.GetFilesystem(0) + require.NoError(t, err) + defer cidata.Close() + + assert.Equal(t, "CIDATA", strings.TrimSpace(cidata.Label())) + assert.Equal(t, payload.UserData, readFile(t, cidata, "/user-data")) + assert.Equal(t, payload.MetaData, readFile(t, cidata, "/meta-data")) + assert.Equal(t, payload.NetworkConfig, readFile(t, cidata, "/network-config")) +} + +func readFile(t *testing.T, fsys fs.ReadFileFS, name string) []byte { + t.Helper() + + data, err := fsys.ReadFile(name) + require.NoError(t, err) + + return data +} diff --git a/tools/labctl/internal/adapters/talosconfig/validator.go b/tools/labctl/internal/adapters/talosconfig/validator.go new file mode 100644 index 0000000..6d9fc28 --- /dev/null +++ b/tools/labctl/internal/adapters/talosconfig/validator.go @@ -0,0 +1,47 @@ +package talosconfig + +import ( + "fmt" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/encoding/yaml" + schematalos "github.com/gilmanlab/platform/schemas/lab/talos" +) + +const imageBuildDefinition = "#ImageBuild" + +// Validator validates Talos image build YAML against the shared CUE schema. +type Validator struct{} + +// New constructs a Validator. +func New() Validator { + return Validator{} +} + +// ValidateYAML validates YAML input and decodes a defaulted ImageBuild. +func (Validator) ValidateYAML(filename string, data []byte) (schematalos.ImageBuild, error) { + ctx := cuecontext.New() + schema := ctx.CompileString(schematalos.SchemaSource()) + if err := schema.Err(); err != nil { + return schematalos.ImageBuild{}, fmt.Errorf("compile Talos schema: %w", err) + } + + source, err := yaml.Extract(filename, data) + if err != nil { + return schematalos.ImageBuild{}, fmt.Errorf("parse %s as YAML: %w", filename, err) + } + + input := ctx.BuildFile(source) + value := schema.LookupPath(cue.ParsePath(imageBuildDefinition)).Unify(input) + if err := value.Validate(cue.Concrete(true)); err != nil { + return schematalos.ImageBuild{}, fmt.Errorf("validate %s: %w", filename, err) + } + + var config schematalos.ImageBuild + if err := value.Decode(&config); err != nil { + return schematalos.ImageBuild{}, fmt.Errorf("decode %s: %w", filename, err) + } + + return config, nil +} diff --git a/tools/labctl/internal/adapters/talosconfig/validator_test.go b/tools/labctl/internal/adapters/talosconfig/validator_test.go new file mode 100644 index 0000000..0bfa378 --- /dev/null +++ b/tools/labctl/internal/adapters/talosconfig/validator_test.go @@ -0,0 +1,100 @@ +package talosconfig_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gilmanlab/platform/tools/labctl/internal/adapters/talosconfig" +) + +func TestValidateYAMLAppliesDefaults(t *testing.T) { + config, err := talosconfig.New().ValidateYAML("valid.yaml", []byte(validConfigYAML())) + + require.NoError(t, err) + assert.Equal(t, "talos-test", string(config.Name)) + assert.Equal(t, "https://factory.talos.dev", config.Source.FactoryURL) + assert.Equal(t, "v1.13.0", string(config.Source.Version)) + assert.Equal(t, "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba", config.Source.SchematicID) + assert.Equal(t, "nocloud", string(config.Source.Platform)) + assert.Equal(t, "amd64", string(config.Source.Arch)) + assert.Equal(t, "raw.xz", string(config.Source.Artifact)) + assert.Equal(t, "nocloud-cidata", string(config.Config.Delivery)) + assert.Equal(t, "controlplane.yaml", string(config.Config.UserData.Path)) + assert.Equal(t, "bootstrap-controlplane-1", string(config.Config.MetaData.LocalHostname)) + assert.Equal(t, "bootstrap-controlplane-1", config.Config.MetaData.InstanceID) + assert.Equal(t, "img", string(config.Output.Format)) +} + +func TestValidateYAMLRejectsInvalidInputs(t *testing.T) { + tests := []struct { + name string + input string + message string + }{ + { + name: "latest version", + input: replaceValidConfig( + "version: v1.13.0", + "version: latest", + ), + message: "Talos version must be an exact release like v1.13.0", + }, + { + name: "unsupported format", + input: replaceValidConfig( + "format: img", + "format: iso", + ), + message: "format must be img", + }, + { + name: "unsupported platform", + input: replaceValidConfig( + "version: v1.13.0", + "version: v1.13.0\n platform: metal", + ), + message: "platform must be nocloud", + }, + { + name: "parent path", + input: replaceValidConfig( + "path: controlplane.yaml", + "path: ../controlplane.yaml", + ), + message: "path must be relative and use forward slashes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := talosconfig.New().ValidateYAML("invalid.yaml", []byte(tt.input)) + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.message) + }) + } +} + +func replaceValidConfig(old string, replacement string) string { + return strings.Replace(validConfigYAML(), old, replacement, 1) +} + +func validConfigYAML() string { + return `name: talos-test +source: + version: v1.13.0 +config: + userData: + path: controlplane.yaml + metaData: + localHostname: bootstrap-controlplane-1 +output: + dir: .state/images + format: img + bootArtifactName: talos-boot.img + configArtifactName: talos-cidata.img +` +} diff --git a/tools/labctl/internal/app/talosimage/service.go b/tools/labctl/internal/app/talosimage/service.go new file mode 100644 index 0000000..4aeea61 --- /dev/null +++ b/tools/labctl/internal/app/talosimage/service.go @@ -0,0 +1,285 @@ +package talosimage + +import ( + "context" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + + schematalos "github.com/gilmanlab/platform/schemas/lab/talos" + "github.com/ulikunitz/xz" + "go.yaml.in/yaml/v4" +) + +const ( + fileModeDir = 0o755 +) + +// Service builds Talos image artifacts. +type Service struct { + upstream Upstream + files FileSystem + configDisk ConfigDiskBuilder +} + +// NewService constructs a Service from external adapters. +func NewService(deps Dependencies) Service { + return Service{ + upstream: deps.Upstream, + files: deps.Files, + configDisk: deps.ConfigDisk, + } +} + +// Build builds Talos boot and NoCloud cidata image artifacts. +func (s Service) Build(ctx context.Context, request Request) (Result, error) { + if err := s.validate(); err != nil { + return Result{}, err + } + + paths, err := resolvePaths(request.BaseDir, request.Config.Output) + if err != nil { + return Result{}, err + } + + err = s.prepareDirectories(paths) + if err != nil { + return Result{}, err + } + + image := buildSourceImage(request.Config.Source) + archivePath := filepath.Join(paths.downloadsDir, image.schematicID, image.version, image.filename) + err = s.prepareDownloadDirectory(archivePath) + if err != nil { + return Result{}, err + } + err = s.downloadArchive(ctx, image.url, archivePath) + if err != nil { + return Result{}, err + } + err = s.writeBootImage(archivePath, paths.bootArtifactPath) + if err != nil { + return Result{}, err + } + + payload, err := s.buildConfigDiskPayload(request.BaseDir, request.Config.Config) + if err != nil { + return Result{}, err + } + if err := s.configDisk.Build(paths.configArtifactPath, payload); err != nil { + return Result{}, fmt.Errorf("build NoCloud cidata image %q: %w", paths.configArtifactPath, err) + } + + return Result{ + Name: string(request.Config.Name), + BootArtifactPath: paths.bootArtifactPath, + ConfigArtifactPath: paths.configArtifactPath, + SourceVersion: image.version, + SourceURL: image.url, + SourceSchematicID: image.schematicID, + Platform: image.platform, + Arch: image.arch, + Format: string(request.Config.Output.Format), + }, nil +} + +func (s Service) validate() error { + if s.upstream == nil { + return errors.New("talos image builder missing upstream adapter") + } + if s.files == nil { + return errors.New("talos image builder missing filesystem adapter") + } + if s.configDisk == nil { + return errors.New("talos image builder missing config disk adapter") + } + + return nil +} + +func (s Service) prepareDirectories(paths buildPaths) error { + if err := s.files.MkdirAll(paths.downloadsDir, fileModeDir); err != nil { + return fmt.Errorf("create downloads directory %q: %w", paths.downloadsDir, err) + } + if err := s.files.MkdirAll(paths.outputDir, fileModeDir); err != nil { + return fmt.Errorf("create output directory %q: %w", paths.outputDir, err) + } + + return nil +} + +func (s Service) prepareDownloadDirectory(archivePath string) error { + dir := filepath.Dir(archivePath) + if err := s.files.MkdirAll(dir, fileModeDir); err != nil { + return fmt.Errorf("create download cache directory %q: %w", dir, err) + } + + return nil +} + +func (s Service) downloadArchive(ctx context.Context, url string, destination string) error { + exists, err := s.files.IsFile(destination) + if err != nil { + return fmt.Errorf("check cached archive %q: %w", destination, err) + } + if exists { + return nil + } + + source, err := s.upstream.Download(ctx, url) + if err != nil { + return fmt.Errorf("download Talos image %q: %w", url, err) + } + defer source.Close() + + target, err := s.files.Create(destination) + if err != nil { + return fmt.Errorf("create archive %q: %w", destination, err) + } + defer target.Close() + + if _, err := io.Copy(target, source); err != nil { + return fmt.Errorf("write archive %q: %w", destination, err) + } + + return nil +} + +func (s Service) writeBootImage(archivePath string, bootArtifactPath string) error { + archive, err := s.files.Open(archivePath) + if err != nil { + return fmt.Errorf("open compressed archive %q: %w", archivePath, err) + } + defer archive.Close() + + raw, err := xz.NewReader(archive) + if err != nil { + return fmt.Errorf("read xz archive %q: %w", archivePath, err) + } + + target, err := s.files.Create(bootArtifactPath) + if err != nil { + return fmt.Errorf("create boot image %q: %w", bootArtifactPath, err) + } + defer target.Close() + + if _, err := io.Copy(target, raw); err != nil { + return fmt.Errorf("decompress boot image %q: %w", bootArtifactPath, err) + } + + return nil +} + +func (s Service) buildConfigDiskPayload(baseDir string, config schematalos.MachineConfig) (ConfigDiskPayload, error) { + userData, err := s.readConfigInput(baseDir, config.UserData, "user-data") + if err != nil { + return ConfigDiskPayload{}, err + } + + metaData, err := buildMetaData(config.MetaData) + if err != nil { + return ConfigDiskPayload{}, err + } + + var networkConfig []byte + if config.NetworkConfig.Path != "" { + networkConfig, err = s.readConfigInput(baseDir, config.NetworkConfig, "network-config") + if err != nil { + return ConfigDiskPayload{}, err + } + } + + return ConfigDiskPayload{ + UserData: userData, + MetaData: metaData, + NetworkConfig: networkConfig, + }, nil +} + +func (s Service) readConfigInput(baseDir string, input schematalos.FileInput, name string) ([]byte, error) { + path, err := resolvePath(baseDir, string(input.Path)) + if err != nil { + return nil, err + } + + source, err := s.files.Open(path) + if err != nil { + return nil, fmt.Errorf("open NoCloud %s input %q: %w", name, path, err) + } + defer source.Close() + + data, err := io.ReadAll(source) + if err != nil { + return nil, fmt.Errorf("read NoCloud %s input %q: %w", name, path, err) + } + + return data, nil +} + +func buildSourceImage(source schematalos.ImageSource) sourceImage { + filename := fmt.Sprintf("%s-%s.%s", source.Platform, source.Arch, source.Artifact) + url := strings.TrimRight(source.FactoryURL, "/") + + "/image/" + source.SchematicID + + "/" + string(source.Version) + + "/" + filename + + return sourceImage{ + version: string(source.Version), + url: url, + filename: filename, + schematicID: source.SchematicID, + platform: string(source.Platform), + arch: string(source.Arch), + } +} + +type noCloudMetaData struct { + InstanceID string `yaml:"instance-id"` + LocalHostname string `yaml:"local-hostname"` +} + +func buildMetaData(metaData schematalos.NoCloudMetaData) ([]byte, error) { + data, err := yaml.Marshal(noCloudMetaData{ + InstanceID: metaData.InstanceID, + LocalHostname: string(metaData.LocalHostname), + }) + if err != nil { + return nil, fmt.Errorf("marshal NoCloud meta-data: %w", err) + } + + return data, nil +} + +type buildPaths struct { + downloadsDir string + outputDir string + bootArtifactPath string + configArtifactPath string +} + +func resolvePaths(baseDir string, output schematalos.ImageOutput) (buildPaths, error) { + outputDir, err := resolvePath(baseDir, string(output.Dir)) + if err != nil { + return buildPaths{}, err + } + + return buildPaths{ + downloadsDir: filepath.Join(filepath.Dir(outputDir), "downloads", "talos"), + outputDir: outputDir, + bootArtifactPath: filepath.Join(outputDir, string(output.BootArtifactName)), + configArtifactPath: filepath.Join(outputDir, string(output.ConfigArtifactName)), + }, nil +} + +func resolvePath(baseDir string, path string) (string, error) { + if filepath.IsAbs(path) { + return filepath.Clean(path), nil + } + if baseDir == "" { + return "", errors.New("base directory is required for relative paths") + } + + return filepath.Abs(filepath.Join(baseDir, path)) +} diff --git a/tools/labctl/internal/app/talosimage/service_test.go b/tools/labctl/internal/app/talosimage/service_test.go new file mode 100644 index 0000000..42ac28e --- /dev/null +++ b/tools/labctl/internal/app/talosimage/service_test.go @@ -0,0 +1,158 @@ +package talosimage_test + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" + + schematalos "github.com/gilmanlab/platform/schemas/lab/talos" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ulikunitz/xz" + + "github.com/gilmanlab/platform/tools/labctl/internal/adapters/localfs" + "github.com/gilmanlab/platform/tools/labctl/internal/app/talosimage" +) + +func TestServiceBuildWritesBootImageAndNoCloudPayload(t *testing.T) { + baseDir := t.TempDir() + writeFixture(t, baseDir, "controlplane.yaml", "machine:\n type: controlplane\n") + writeFixture(t, baseDir, "network.yaml", "version: 1\n") + + raw := []byte("talos-raw-image") + upstream := &fakeUpstream{artifact: xzBytes(t, raw)} + configDisk := &fakeConfigDiskBuilder{} + service := talosimage.NewService(talosimage.Dependencies{ + Upstream: upstream, + Files: localfs.New(), + ConfigDisk: configDisk, + }) + + result, err := service.Build(context.Background(), talosimage.Request{ + Config: testConfig(), + BaseDir: baseDir, + }) + + require.NoError(t, err) + assert.Equal(t, "talos-test", result.Name) + assert.Equal( + t, + "https://factory.example.test/image/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/v1.13.0/nocloud-amd64.raw.xz", + result.SourceURL, + ) + assert.Equal(t, "v1.13.0", result.SourceVersion) + assert.Equal(t, "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba", result.SourceSchematicID) + assert.Equal(t, "nocloud", result.Platform) + assert.Equal(t, "amd64", result.Arch) + assert.Equal(t, "img", result.Format) + assert.Equal(t, result.SourceURL, upstream.url) + + boot, err := os.ReadFile(filepath.Clean(result.BootArtifactPath)) + require.NoError(t, err) + assert.Equal(t, raw, boot) + + require.Equal(t, result.ConfigArtifactPath, configDisk.path) + assert.Equal(t, []byte("machine:\n type: controlplane\n"), configDisk.payload.UserData) + assert.Contains(t, string(configDisk.payload.MetaData), "instance-id: bootstrap-instance-1") + assert.Contains(t, string(configDisk.payload.MetaData), "local-hostname: bootstrap-controlplane-1") + assert.Equal(t, []byte("version: 1\n"), configDisk.payload.NetworkConfig) + + cachePath := filepath.Join( + baseDir, + ".state", + "downloads", + "talos", + "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba", + "v1.13.0", + "nocloud-amd64.raw.xz", + ) + assert.FileExists(t, cachePath) + assert.Equal(t, 1, upstream.downloads) + + _, err = service.Build(context.Background(), talosimage.Request{ + Config: testConfig(), + BaseDir: baseDir, + }) + require.NoError(t, err) + assert.Equal(t, 1, upstream.downloads, "expected second build to reuse the cached archive") +} + +func testConfig() schematalos.ImageBuild { + return schematalos.ImageBuild{ + Name: "talos-test", + Source: schematalos.ImageSource{ + FactoryURL: "https://factory.example.test", + Version: "v1.13.0", + SchematicID: "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba", + Platform: "nocloud", + Arch: "amd64", + Artifact: "raw.xz", + }, + Config: schematalos.MachineConfig{ + Delivery: "nocloud-cidata", + UserData: schematalos.FileInput{ + Path: "controlplane.yaml", + }, + MetaData: schematalos.NoCloudMetaData{ + LocalHostname: "bootstrap-controlplane-1", + InstanceID: "bootstrap-instance-1", + }, + NetworkConfig: schematalos.FileInput{ + Path: "network.yaml", + }, + }, + Output: schematalos.ImageOutput{ + Dir: ".state/images", + Format: "img", + BootArtifactName: "talos-boot.img", + ConfigArtifactName: "talos-cidata.img", + }, + } +} + +type fakeUpstream struct { + artifact []byte + downloads int + url string +} + +func (f *fakeUpstream) Download(_ context.Context, url string) (io.ReadCloser, error) { + f.downloads++ + f.url = url + + return io.NopCloser(bytes.NewReader(f.artifact)), nil +} + +type fakeConfigDiskBuilder struct { + path string + payload talosimage.ConfigDiskPayload +} + +func (f *fakeConfigDiskBuilder) Build(path string, payload talosimage.ConfigDiskPayload) error { + f.path = path + f.payload = payload + + return nil +} + +func writeFixture(t *testing.T, dir string, name string, data string) { + t.Helper() + + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(data), 0o600)) +} + +func xzBytes(t *testing.T, data []byte) []byte { + t.Helper() + + var buffer bytes.Buffer + writer, err := xz.NewWriter(&buffer) + require.NoError(t, err) + _, err = writer.Write(data) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + return buffer.Bytes() +} diff --git a/tools/labctl/internal/app/talosimage/types.go b/tools/labctl/internal/app/talosimage/types.go new file mode 100644 index 0000000..1f2c29a --- /dev/null +++ b/tools/labctl/internal/app/talosimage/types.go @@ -0,0 +1,86 @@ +package talosimage + +import ( + "context" + "io" + "io/fs" + + schematalos "github.com/gilmanlab/platform/schemas/lab/talos" +) + +// Request describes a Talos image build request. +type Request struct { + // Config is the validated Talos image build configuration. + Config schematalos.ImageBuild + // BaseDir resolves relative paths in Config. + BaseDir string +} + +// Result describes the built Talos image artifacts. +type Result struct { + // Name is the stable build name from the input configuration. + Name string `json:"name"` + // BootArtifactPath is the local path to the Talos boot disk image. + BootArtifactPath string `json:"bootArtifactPath"` + // ConfigArtifactPath is the local path to the NoCloud cidata image. + ConfigArtifactPath string `json:"configArtifactPath"` + // SourceVersion is the selected upstream Talos Linux version. + SourceVersion string `json:"sourceVersion"` + // SourceURL is the selected upstream Talos Image Factory artifact URL. + SourceURL string `json:"sourceURL"` + // SourceSchematicID is the Image Factory schematic ID used for the artifact. + SourceSchematicID string `json:"sourceSchematicID"` + // Platform is the Talos Image Factory platform. + Platform string `json:"platform"` + // Arch is the Talos Image Factory architecture. + Arch string `json:"arch"` + // Format is the local artifact format. + Format string `json:"format"` +} + +// Dependencies groups external ports needed to build Talos image artifacts. +type Dependencies struct { + // Upstream downloads Talos Image Factory artifacts. + Upstream Upstream + // Files reads and writes local build artifacts. + Files FileSystem + // ConfigDisk builds NoCloud cidata images. + ConfigDisk ConfigDiskBuilder +} + +// Upstream downloads Talos Image Factory artifact bytes. +type Upstream interface { + Download(ctx context.Context, url string) (io.ReadCloser, error) +} + +// FileSystem describes the local filesystem behavior used by the builder. +type FileSystem interface { + MkdirAll(path string, perm fs.FileMode) error + IsFile(path string) (bool, error) + Open(path string) (io.ReadCloser, error) + Create(path string) (io.WriteCloser, error) +} + +// ConfigDiskBuilder writes NoCloud cidata images. +type ConfigDiskBuilder interface { + Build(path string, payload ConfigDiskPayload) error +} + +// ConfigDiskPayload contains the NoCloud files written to the cidata image. +type ConfigDiskPayload struct { + // UserData is the Talos machine config written to user-data. + UserData []byte + // MetaData is the NoCloud meta-data YAML. + MetaData []byte + // NetworkConfig is the optional NoCloud network-config YAML. + NetworkConfig []byte +} + +type sourceImage struct { + version string + url string + filename string + schematicID string + platform string + arch string +} diff --git a/tools/labctl/internal/cli/bootstrap.go b/tools/labctl/internal/cli/bootstrap.go index aa7a8c5..d7bdb34 100644 --- a/tools/labctl/internal/cli/bootstrap.go +++ b/tools/labctl/internal/cli/bootstrap.go @@ -12,6 +12,7 @@ import ( "github.com/gilmanlab/platform/tools/labctl/internal/adapters/secretrefs" "github.com/gilmanlab/platform/tools/labctl/internal/app/incusosimage" appsecrets "github.com/gilmanlab/platform/tools/labctl/internal/app/secrets" + "github.com/gilmanlab/platform/tools/labctl/internal/app/talosimage" "github.com/gilmanlab/platform/tools/labctl/internal/composition" ) @@ -25,6 +26,18 @@ type imageBuildOutput struct { SourceSHA256 string `json:"sourceSHA256"` } +type talosImageBuildOutput struct { + Name string `json:"name"` + BootArtifactPath string `json:"bootArtifactPath"` + ConfigArtifactPath string `json:"configArtifactPath"` + SourceVersion string `json:"sourceVersion"` + SourceURL string `json:"sourceURL"` + SourceSchematicID string `json:"sourceSchematicID"` + Platform string `json:"platform"` + Arch string `json:"arch"` + Format string `json:"format"` +} + type imageBuildSecretsFlags struct { ref string source string @@ -40,6 +53,7 @@ func newBootstrapCommand(deps composition.Dependencies, opts Options, flags *roo } cmd.AddCommand(newBootstrapIncusOSCommand(deps, opts, flags)) + cmd.AddCommand(newBootstrapTalosCommand(deps, opts, flags)) return cmd } @@ -138,6 +152,67 @@ func newBootstrapIncusOSImageBuildCommand( return cmd } +func newBootstrapTalosCommand(deps composition.Dependencies, opts Options, flags *rootFlags) *cobra.Command { + cmd := &cobra.Command{ + Use: "talos", + Short: "Build Talos bootstrap artifacts", + } + + cmd.AddCommand(newBootstrapTalosImageCommand(deps, opts, flags)) + + return cmd +} + +func newBootstrapTalosImageCommand(deps composition.Dependencies, opts Options, flags *rootFlags) *cobra.Command { + cmd := &cobra.Command{ + Use: "image", + Short: "Build Talos images", + } + + cmd.AddCommand(newBootstrapTalosImageBuildCommand(deps, opts, flags)) + + return cmd +} + +func newBootstrapTalosImageBuildCommand( + deps composition.Dependencies, + opts Options, + flags *rootFlags, +) *cobra.Command { + var jsonOutput bool + + cmd := &cobra.Command{ + Use: "build ", + Short: "Build Talos boot and NoCloud images", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + input, err := readImageBuildInput(args[0], opts) + if err != nil { + return err + } + + config, err := deps.TalosConfig.ValidateYAML(input.name, input.data) + if err != nil { + return err + } + + result, err := deps.TalosImage.Build(cmd.Context(), talosimage.Request{ + Config: config, + BaseDir: input.baseDir, + }) + if err != nil { + return err + } + + return renderTalosImageBuildResult(result, opts, flags, jsonOutput) + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "print build result as JSON") + + return cmd +} + type imageBuildInput struct { name string data []byte @@ -198,3 +273,30 @@ func renderImageBuildResult(result incusosimage.Result, opts Options, flags *roo return writeLine(opts.Stdout, "%s", result.ArtifactPath) } + +func renderTalosImageBuildResult(result talosimage.Result, opts Options, flags *rootFlags, jsonOutput bool) error { + output := talosImageBuildOutput{ + Name: result.Name, + BootArtifactPath: result.BootArtifactPath, + ConfigArtifactPath: result.ConfigArtifactPath, + SourceVersion: result.SourceVersion, + SourceURL: result.SourceURL, + SourceSchematicID: result.SourceSchematicID, + Platform: result.Platform, + Arch: result.Arch, + Format: result.Format, + } + + if jsonOutput { + return writeJSON(opts.Stdout, output) + } + if flags.quiet { + return nil + } + + if err := writeLine(opts.Stdout, "%s", result.BootArtifactPath); err != nil { + return err + } + + return writeLine(opts.Stdout, "%s", result.ConfigArtifactPath) +} diff --git a/tools/labctl/internal/composition/composition.go b/tools/labctl/internal/composition/composition.go index 2ec046c..87b6717 100644 --- a/tools/labctl/internal/composition/composition.go +++ b/tools/labctl/internal/composition/composition.go @@ -9,11 +9,14 @@ import ( "github.com/gilmanlab/platform/tools/labctl/internal/adapters/httpupstream" "github.com/gilmanlab/platform/tools/labctl/internal/adapters/incusosconfig" "github.com/gilmanlab/platform/tools/labctl/internal/adapters/localfs" + "github.com/gilmanlab/platform/tools/labctl/internal/adapters/nocloudcidata" "github.com/gilmanlab/platform/tools/labctl/internal/adapters/secretslocal" "github.com/gilmanlab/platform/tools/labctl/internal/adapters/sopsdecrypt" + "github.com/gilmanlab/platform/tools/labctl/internal/adapters/talosconfig" "github.com/gilmanlab/platform/tools/labctl/internal/adapters/yamldoc" "github.com/gilmanlab/platform/tools/labctl/internal/app/incusosimage" appsecrets "github.com/gilmanlab/platform/tools/labctl/internal/app/secrets" + "github.com/gilmanlab/platform/tools/labctl/internal/app/talosimage" appversion "github.com/gilmanlab/platform/tools/labctl/internal/app/version" ) @@ -37,6 +40,10 @@ type Dependencies struct { IncusOSImage incusosimage.Service // IncusOSConfig validates IncusOS image build input. IncusOSConfig incusosconfig.Validator + // TalosImage builds Talos bootstrap image artifacts. + TalosImage talosimage.Service + // TalosConfig validates Talos image build input. + TalosConfig talosconfig.Validator } // New wires app services to their concrete adapters. @@ -70,5 +77,11 @@ func New(input Input) Dependencies { Files: files, }), IncusOSConfig: incusosconfig.New(), + TalosImage: talosimage.NewService(talosimage.Dependencies{ + Upstream: httpupstream.New(httpClient), + Files: files, + ConfigDisk: nocloudcidata.New(), + }), + TalosConfig: talosconfig.New(), } }