feat: download talosctl directly from factory

Download talosctl binaries from factory instead of Github

Signed-off-by: Edward Sammut Alessi <edward.sammutalessi@siderolabs.com>
This commit is contained in:
Edward Sammut Alessi 2026-04-29 16:54:31 +02:00
parent b2671d08d0
commit d3592671ec
No known key found for this signature in database
GPG Key ID: 65558E016966977A
20 changed files with 725 additions and 323 deletions

View File

@ -927,6 +927,58 @@ func (x *SupportSpec) GetOfficeHours() *OfficeHoursConfig {
return nil
}
type QuirksSpec struct {
state protoimpl.MessageState `protogen:"open.v1"`
SupportsUnifiedInstaller bool `protobuf:"varint,1,opt,name=supports_unified_installer,json=supportsUnifiedInstaller,proto3" json:"supports_unified_installer,omitempty"`
SupportsFactoryTalosctl bool `protobuf:"varint,2,opt,name=supports_factory_talosctl,json=supportsFactoryTalosctl,proto3" json:"supports_factory_talosctl,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *QuirksSpec) Reset() {
*x = QuirksSpec{}
mi := &file_omni_specs_virtual_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *QuirksSpec) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*QuirksSpec) ProtoMessage() {}
func (x *QuirksSpec) ProtoReflect() protoreflect.Message {
mi := &file_omni_specs_virtual_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use QuirksSpec.ProtoReflect.Descriptor instead.
func (*QuirksSpec) Descriptor() ([]byte, []int) {
return file_omni_specs_virtual_proto_rawDescGZIP(), []int{9}
}
func (x *QuirksSpec) GetSupportsUnifiedInstaller() bool {
if x != nil {
return x.SupportsUnifiedInstaller
}
return false
}
func (x *QuirksSpec) GetSupportsFactoryTalosctl() bool {
if x != nil {
return x.SupportsFactoryTalosctl
}
return false
}
type LabelsCompletionSpec_Values struct {
state protoimpl.MessageState `protogen:"open.v1"`
Items []string `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
@ -936,7 +988,7 @@ type LabelsCompletionSpec_Values struct {
func (x *LabelsCompletionSpec_Values) Reset() {
*x = LabelsCompletionSpec_Values{}
mi := &file_omni_specs_virtual_proto_msgTypes[9]
mi := &file_omni_specs_virtual_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -948,7 +1000,7 @@ func (x *LabelsCompletionSpec_Values) String() string {
func (*LabelsCompletionSpec_Values) ProtoMessage() {}
func (x *LabelsCompletionSpec_Values) ProtoReflect() protoreflect.Message {
mi := &file_omni_specs_virtual_proto_msgTypes[9]
mi := &file_omni_specs_virtual_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1068,7 +1120,11 @@ const file_omni_specs_virtual_proto_rawDesc = "" +
"\bmeet_url\x18\b \x01(\tR\ameetUrl\"s\n" +
"\vSupportSpec\x12'\n" +
"\x0fsupport_enabled\x18\x01 \x01(\bR\x0esupportEnabled\x12;\n" +
"\foffice_hours\x18\x02 \x01(\v2\x18.specs.OfficeHoursConfigR\vofficeHoursB2Z0github.com/siderolabs/omni/client/api/omni/specsb\x06proto3"
"\foffice_hours\x18\x02 \x01(\v2\x18.specs.OfficeHoursConfigR\vofficeHours\"\x86\x01\n" +
"\n" +
"QuirksSpec\x12<\n" +
"\x1asupports_unified_installer\x18\x01 \x01(\bR\x18supportsUnifiedInstaller\x12:\n" +
"\x19supports_factory_talosctl\x18\x02 \x01(\bR\x17supportsFactoryTalosctlB2Z0github.com/siderolabs/omni/client/api/omni/specsb\x06proto3"
var (
file_omni_specs_virtual_proto_rawDescOnce sync.Once
@ -1083,7 +1139,7 @@ func file_omni_specs_virtual_proto_rawDescGZIP() []byte {
}
var file_omni_specs_virtual_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_omni_specs_virtual_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_omni_specs_virtual_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_omni_specs_virtual_proto_goTypes = []any{
(PlatformConfigSpec_BootMethod)(0), // 0: specs.PlatformConfigSpec.BootMethod
(PlatformConfigSpec_Arch)(0), // 1: specs.PlatformConfigSpec.Arch
@ -1096,15 +1152,16 @@ var file_omni_specs_virtual_proto_goTypes = []any{
(*SBCConfigSpec)(nil), // 8: specs.SBCConfigSpec
(*OfficeHoursConfig)(nil), // 9: specs.OfficeHoursConfig
(*SupportSpec)(nil), // 10: specs.SupportSpec
(*LabelsCompletionSpec_Values)(nil), // 11: specs.LabelsCompletionSpec.Values
nil, // 12: specs.LabelsCompletionSpec.ItemsEntry
(*QuirksSpec)(nil), // 11: specs.QuirksSpec
(*LabelsCompletionSpec_Values)(nil), // 12: specs.LabelsCompletionSpec.Values
nil, // 13: specs.LabelsCompletionSpec.ItemsEntry
}
var file_omni_specs_virtual_proto_depIdxs = []int32{
12, // 0: specs.LabelsCompletionSpec.items:type_name -> specs.LabelsCompletionSpec.ItemsEntry
13, // 0: specs.LabelsCompletionSpec.items:type_name -> specs.LabelsCompletionSpec.ItemsEntry
1, // 1: specs.PlatformConfigSpec.architectures:type_name -> specs.PlatformConfigSpec.Arch
0, // 2: specs.PlatformConfigSpec.boot_methods:type_name -> specs.PlatformConfigSpec.BootMethod
9, // 3: specs.SupportSpec.office_hours:type_name -> specs.OfficeHoursConfig
11, // 4: specs.LabelsCompletionSpec.ItemsEntry.value:type_name -> specs.LabelsCompletionSpec.Values
12, // 4: specs.LabelsCompletionSpec.ItemsEntry.value:type_name -> specs.LabelsCompletionSpec.Values
5, // [5:5] is the sub-list for method output_type
5, // [5:5] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
@ -1123,7 +1180,7 @@ func file_omni_specs_virtual_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_omni_specs_virtual_proto_rawDesc), len(file_omni_specs_virtual_proto_rawDesc)),
NumEnums: 2,
NumMessages: 11,
NumMessages: 12,
NumExtensions: 0,
NumServices: 0,
},

View File

@ -107,3 +107,8 @@ message SupportSpec {
bool support_enabled = 1;
OfficeHoursConfig office_hours = 2;
}
message QuirksSpec {
bool supports_unified_installer = 1;
bool supports_factory_talosctl = 2;
}

View File

@ -259,6 +259,24 @@ func (m *SupportSpec) CloneMessageVT() proto.Message {
return m.CloneVT()
}
func (m *QuirksSpec) CloneVT() *QuirksSpec {
if m == nil {
return (*QuirksSpec)(nil)
}
r := new(QuirksSpec)
r.SupportsUnifiedInstaller = m.SupportsUnifiedInstaller
r.SupportsFactoryTalosctl = m.SupportsFactoryTalosctl
if len(m.unknownFields) > 0 {
r.unknownFields = make([]byte, len(m.unknownFields))
copy(r.unknownFields, m.unknownFields)
}
return r
}
func (m *QuirksSpec) CloneMessageVT() proto.Message {
return m.CloneVT()
}
func (this *CurrentUserSpec) EqualVT(that *CurrentUserSpec) bool {
if this == that {
return true
@ -637,6 +655,28 @@ func (this *SupportSpec) EqualMessageVT(thatMsg proto.Message) bool {
}
return this.EqualVT(that)
}
func (this *QuirksSpec) EqualVT(that *QuirksSpec) bool {
if this == that {
return true
} else if this == nil || that == nil {
return false
}
if this.SupportsUnifiedInstaller != that.SupportsUnifiedInstaller {
return false
}
if this.SupportsFactoryTalosctl != that.SupportsFactoryTalosctl {
return false
}
return string(this.unknownFields) == string(that.unknownFields)
}
func (this *QuirksSpec) EqualMessageVT(thatMsg proto.Message) bool {
that, ok := thatMsg.(*QuirksSpec)
if !ok {
return false
}
return this.EqualVT(that)
}
func (m *CurrentUserSpec) MarshalVT() (dAtA []byte, err error) {
if m == nil {
return nil, nil
@ -1552,6 +1592,59 @@ func (m *SupportSpec) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
return len(dAtA) - i, nil
}
func (m *QuirksSpec) MarshalVT() (dAtA []byte, err error) {
if m == nil {
return nil, nil
}
size := m.SizeVT()
dAtA = make([]byte, size)
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *QuirksSpec) MarshalToVT(dAtA []byte) (int, error) {
size := m.SizeVT()
return m.MarshalToSizedBufferVT(dAtA[:size])
}
func (m *QuirksSpec) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
if m == nil {
return 0, nil
}
i := len(dAtA)
_ = i
var l int
_ = l
if m.unknownFields != nil {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if m.SupportsFactoryTalosctl {
i--
if m.SupportsFactoryTalosctl {
dAtA[i] = 1
} else {
dAtA[i] = 0
}
i--
dAtA[i] = 0x10
}
if m.SupportsUnifiedInstaller {
i--
if m.SupportsUnifiedInstaller {
dAtA[i] = 1
} else {
dAtA[i] = 0
}
i--
dAtA[i] = 0x8
}
return len(dAtA) - i, nil
}
func (m *CurrentUserSpec) SizeVT() (n int) {
if m == nil {
return 0
@ -1879,6 +1972,22 @@ func (m *SupportSpec) SizeVT() (n int) {
return n
}
func (m *QuirksSpec) SizeVT() (n int) {
if m == nil {
return 0
}
var l int
_ = l
if m.SupportsUnifiedInstaller {
n += 2
}
if m.SupportsFactoryTalosctl {
n += 2
}
n += len(m.unknownFields)
return n
}
func (m *CurrentUserSpec) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
@ -4108,3 +4217,94 @@ func (m *SupportSpec) UnmarshalVT(dAtA []byte) error {
}
return nil
}
func (m *QuirksSpec) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return protohelpers.ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: QuirksSpec: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: QuirksSpec: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
case 1:
if wireType != 0 {
return fmt.Errorf("proto: wrong wireType = %d for field SupportsUnifiedInstaller", wireType)
}
var v int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return protohelpers.ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
v |= int(b&0x7F) << shift
if b < 0x80 {
break
}
}
m.SupportsUnifiedInstaller = bool(v != 0)
case 2:
if wireType != 0 {
return fmt.Errorf("proto: wrong wireType = %d for field SupportsFactoryTalosctl", wireType)
}
var v int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return protohelpers.ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
v |= int(b&0x7F) << shift
if b < 0x80 {
break
}
}
m.SupportsFactoryTalosctl = bool(v != 0)
default:
iNdEx = preIndex
skippy, err := protohelpers.Skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return protohelpers.ErrInvalidLength
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}

View File

@ -0,0 +1,49 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package virtual
import (
"github.com/cosi-project/runtime/pkg/resource"
"github.com/cosi-project/runtime/pkg/resource/meta"
"github.com/cosi-project/runtime/pkg/resource/protobuf"
"github.com/cosi-project/runtime/pkg/resource/typed"
"github.com/siderolabs/omni/client/api/omni/specs"
"github.com/siderolabs/omni/client/pkg/omni/resources"
)
// NewQuirks creates a new Quirks resource.
func NewQuirks(id string) *Quirks {
return typed.NewResource[QuirksSpec, QuirksExtension](
resource.NewMetadata(resources.VirtualNamespace, QuirksType, id, resource.VersionUndefined),
protobuf.NewResourceSpec(&specs.QuirksSpec{}),
)
}
const (
// QuirksType is the type of Quirks resource.
//
// tsgen:QuirksType
QuirksType = resource.Type("Quirks.omni.sidero.dev")
)
// Quirks resource describes the current Stripe subscription plan.
type Quirks = typed.Resource[QuirksSpec, QuirksExtension]
// QuirksSpec wraps specs.QuirksSpec.
type QuirksSpec = protobuf.ResourceSpec[specs.QuirksSpec, *specs.QuirksSpec]
// QuirksExtension provides auxiliary methods for Quirks resource.
type QuirksExtension struct{}
// ResourceDefinition implements [typed.Extension] interface.
func (QuirksExtension) ResourceDefinition() meta.ResourceDefinitionSpec {
return meta.ResourceDefinitionSpec{
Type: QuirksType,
Aliases: []resource.Type{},
DefaultNamespace: resources.VirtualNamespace,
PrintColumns: []meta.PrintColumn{},
}
}

View File

@ -18,4 +18,5 @@ func init() {
registry.MustRegisterResource(CloudPlatformConfigType, &CloudPlatformConfig{})
registry.MustRegisterResource(MetalPlatformConfigType, &MetalPlatformConfig{})
registry.MustRegisterResource(SupportType, &Support{})
registry.MustRegisterResource(QuirksType, &Quirks{})
}

View File

@ -132,6 +132,26 @@ test('Download omniconfig', async ({ page }, testInfo) => {
})
})
test('Download talosctl', async ({ page }, testInfo) => {
await page.goto('/')
await page.getByRole('button', { name: 'Download talosctl' }).click()
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('link', { name: 'Download', exact: true }).click(),
])
await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible()
const filePath = testInfo.outputPath(download.suggestedFilename())
await download.saveAs(filePath)
const { size } = await stat(filePath)
expect(size).toBeGreaterThan(5 * 1024 * 1024)
})
test('Download omnictl', async ({ page }, testInfo) => {
await page.goto('/')

View File

@ -106,4 +106,9 @@ export type OfficeHoursConfig = {
export type SupportSpec = {
support_enabled?: boolean
office_hours?: OfficeHoursConfig
}
export type QuirksSpec = {
supports_unified_installer?: boolean
supports_factory_talosctl?: boolean
}

View File

@ -287,6 +287,7 @@ export const LabelsCompletionType = "LabelsCompletions.omni.sidero.dev";
export const MetalPlatformConfigType = "MetalPlatformConfigs.omni.sidero.dev";
export const PermissionsID = "permissions";
export const PermissionsType = "Permissions.omni.sidero.dev";
export const QuirksType = "Quirks.omni.sidero.dev";
export const SBCConfigType = "SBCConfigs.omni.sidero.dev";
export const SupportID = "support";
export const SupportType = "Supports.omni.sidero.dev";

View File

@ -31,6 +31,7 @@ const props = withDefaults(
actionLabel?: string
cancelLabel?: string
actionDisabled?: boolean
actionHref?: string
loading?: boolean
}
>(),
@ -45,6 +46,7 @@ const dialogRootProps = reactiveOmit(
'actionLabel',
'cancelLabel',
'actionDisabled',
'actionHref',
'loading',
)
const forwarded = useForwardPropsEmits(dialogRootProps, emit)
@ -58,7 +60,7 @@ const forwarded = useForwardPropsEmits(dialogRootProps, emit)
/>
<DialogContent
class="fixed top-1/2 left-1/2 z-100 flex max-h-screen max-w-screen -translate-1/2 flex-col rounded-sm bg-naturals-n3 p-8 zoom-in-75 zoom-out-75 fade-in fade-out data-[state=closed]:animate-out data-[state=open]:animate-in"
class="fixed top-1/2 left-1/2 z-30 flex max-h-screen max-w-screen -translate-1/2 flex-col rounded-sm bg-naturals-n3 p-8 zoom-in-75 zoom-out-75 fade-in fade-out data-[state=closed]:animate-out data-[state=open]:animate-in"
>
<div class="mb-5 flex items-start justify-between gap-4">
<div class="flex flex-col">
@ -89,6 +91,7 @@ const forwarded = useForwardPropsEmits(dialogRootProps, emit)
v-if="actionLabel"
:disabled="actionDisabled || loading"
variant="highlighted"
v-bind="actionHref ? { is: 'a', href: actionHref } : { is: 'button' }"
@click="$emit('confirm')"
>
<TSpinner v-if="loading" class="size-5" />

View File

@ -3,52 +3,47 @@
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
import { computedAsync } from '@vueuse/core'
import { compareLoose, gte } from 'semver'
import { computed } from 'vue'
import { type MaybeRefOrGetter, ref, toValue } from 'vue'
import { MinTalosVersion } from '@/api/resources'
import { showError } from '@/notification'
import { DefaultTalosVersion } from '@/api/resources'
export interface TalosctlDownloadsResponse {
status: string
release_data: {
/**
* NOTE: We don't use this response value as it is not correct.
* The backend pops the top of a sorted list, but it is sorted alphabetically,
* which does not correctly sort versions. For example, this sorting:
* - 1.1
* - 1.11
* - 1.2
*
* Giving 1.2 as a more recent version than 1.11.
*
* @deprecated Don't use this as it is not the latest version.
*/
default_version: string
available_versions: Record<string, { name: string; url: string }[]>
}
downloads?: string[]
}
export function useTalosctlDownloads() {
const downloads = computedAsync(async () => {
try {
const response = await fetch('/talosctl/downloads')
const {
release_data: { available_versions },
}: TalosctlDownloadsResponse = await response.json()
return new Map(
Object.entries(available_versions)
.filter(([v]) => gte(v, MinTalosVersion))
.sort(([a], [b]) => compareLoose(a, b)),
)
} catch (e) {
showError('Error getting latest talos releases', e?.message ?? String(e))
}
})
const defaultVersion = computed(() => downloads.value && Array.from(downloads.value.keys()).pop())
return { downloads, defaultVersion }
interface Options {
skip?: boolean
}
export function useTalosctlDownloads(
talosVersionMaybeRef?: MaybeRefOrGetter<string | undefined>,
options?: MaybeRefOrGetter<Options>,
) {
const loading = ref(false)
const err = ref<Error>()
const data = computedAsync(
async () => {
try {
if (toValue(options)?.skip) return []
const talosVersion = toValue(talosVersionMaybeRef) ?? DefaultTalosVersion
const response = await fetch(`/talosctl/downloads/${talosVersion}`)
const { downloads }: TalosctlDownloadsResponse = await response.json()
return downloads ?? []
} catch (e) {
err.value = e instanceof Error ? e : new Error(String(e))
return []
}
},
[],
loading,
)
return { data, loading, err }
}

View File

@ -19,6 +19,7 @@ import {
type PlatformConfigSpec,
PlatformConfigSpecArch,
PlatformConfigSpecBootMethod,
type QuirksSpec,
type SBCConfigSpec,
} from '@/api/omni/specs/virtual.pb'
import {
@ -29,6 +30,7 @@ import {
LabelsMeta,
MetalPlatformConfigType,
PlatformMetalID,
QuirksType,
SBCConfigType,
VirtualNamespace,
} from '@/api/resources'
@ -83,26 +85,40 @@ export const Default = {
],
}).handler,
http.get('/talosctl/downloads', () => {
const versions = Object.fromEntries(
faker.helpers.multiple(faker.system.semver).map(
(v) =>
[
`v${v}`,
faker.helpers.multiple(faker.hacker.noun, { count: 5 }).map((name) => ({
name,
url: `https://github.com/siderolabs/talos/releases/download/v${v}/talosctl-${name}-${faker.helpers.arrayElement(['amd64', 'arm64'])}`,
})),
] as const,
),
)
http.post<never, GetRequest, GetResponse>(
'/omni.resources.ResourceService/Get',
async ({ request }) => {
const { id, type, namespace } = await request.clone().json()
if (type !== QuirksType || namespace !== VirtualNamespace) return
return HttpResponse.json({
body: JSON.stringify({
metadata: {
namespace,
type,
id,
},
spec: {
supports_unified_installer: true,
supports_factory_talosctl: true,
},
} satisfies Resource<QuirksSpec>),
})
},
),
http.get<{ version: string }>('/talosctl/downloads/:version', ({ params: { version } }) => {
const downloads = faker.helpers
.multiple(faker.hacker.noun, { count: 5 })
.map(
(name) =>
`https://factory.talos.dev/talosctl/v${version}/talosctl-${name}-${faker.helpers.arrayElement(['amd64', 'arm64'])}`,
)
return HttpResponse.json<TalosctlDownloadsResponse>({
status: '',
release_data: {
default_version: '',
available_versions: versions,
},
downloads,
})
}),

View File

@ -3,68 +3,97 @@
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
import { faker } from '@faker-js/faker'
import { createWatchStreamHandler } from '@msw/helpers'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { http, HttpResponse } from 'msw'
import { compare } from 'semver'
import type { Resource } from '@/api/grpc'
import type { ListRequest, ListResponse } from '@/api/omni/resources/resources.pb'
import type { TalosVersionSpec } from '@/api/omni/specs/omni.pb'
import type { QuirksSpec } from '@/api/omni/specs/virtual.pb'
import {
DefaultNamespace,
DefaultTalosVersion,
QuirksType,
TalosVersionType,
VirtualNamespace,
} from '@/api/resources'
import type { TalosctlDownloadsResponse } from '@/methods/useTalosctlDownloads'
import DownloadTalosctl from './DownloadTalosctl.vue'
const meta: Meta<typeof DownloadTalosctl> = {
component: DownloadTalosctl,
args: {
open: true,
},
}
export default meta
type Story = StoryObj<typeof meta>
const versions = faker.helpers
.uniqueArray(faker.system.semver, 10)
.uniqueArray<string>(
() => `1.${faker.number.int({ min: 8, max: 13 })}.${faker.number.int({ min: 0, max: 10 })}`,
40,
)
.concat(DefaultTalosVersion)
.sort(compare)
.map((v) => `v${v}`)
export const Default: Story = {
parameters: {
msw: {
handlers: [
http.get('/talosctl/downloads', () =>
HttpResponse.json({
release_data: {
available_versions: versions.reduce<Record<string, { name: string; url: string }[]>>(
(prev, curr) => ({
...prev,
[curr]: [
{
name: 'Apple',
url: `https://github.com/siderolabs/talos/releases/download/${curr}/talosctl-darwin-amd64`,
},
{
name: 'Apple Silicon',
url: `https://github.com/siderolabs/talos/releases/download/${curr}/talosctl-darwin-arm64`,
},
{
name: 'Linux',
url: `https://github.com/siderolabs/talos/releases/download/${curr}/talosctl-linux-amd64`,
},
{
name: 'Linux ARM',
url: `https://github.com/siderolabs/talos/releases/download/${curr}/talosctl-linux-armv7`,
},
{
name: 'Linux ARM64',
url: `https://github.com/siderolabs/talos/releases/download/${curr}/talosctl-linux-arm64`,
},
{
name: 'Windows',
url: `https://github.com/siderolabs/talos/releases/download/${curr}/talosctl-windows-amd64.exe`,
},
],
}),
{},
http.post<never, ListRequest, ListResponse>(
'/omni.resources.ResourceService/List',
async ({ request }) => {
const { type, namespace } = await request.clone().json()
if (type !== QuirksType || namespace !== VirtualNamespace) return
return HttpResponse.json({
total: versions.length,
items: versions.map((version) =>
JSON.stringify({
metadata: {
namespace,
type,
id: version,
},
spec: {
supports_factory_talosctl: faker.datatype.boolean(),
},
} satisfies Resource<QuirksSpec>),
),
default_version: versions.at(-1),
},
status: 'ok',
}),
})
},
),
createWatchStreamHandler<TalosVersionSpec>({
expectedOptions: {
type: TalosVersionType,
namespace: DefaultNamespace,
},
initialResources: versions.map((version) => ({
spec: { version, deprecated: faker.datatype.boolean() },
metadata: { id: version },
})),
}).handler,
http.get<{ version: string }>('/talosctl/downloads/:version', ({ params: { version } }) => {
const downloads = faker.helpers
.multiple(faker.hacker.noun, { count: 5 })
.map(
(name) =>
`https://factory.talos.dev/talosctl/v${version}/talosctl-${name}-${faker.helpers.arrayElement(['amd64', 'arm64'])}`,
)
return HttpResponse.json<TalosctlDownloadsResponse>({
status: '',
downloads,
})
}),
],
},
},

View File

@ -6,60 +6,124 @@ included in the LICENSE file.
-->
<script setup lang="ts">
import { computedAsync } from '@vueuse/core'
import { computed, ref } from 'vue'
import { compare } from 'semver'
import { computed, ref, toValue, watchEffect } from 'vue'
import { Runtime } from '@/api/common/omni.pb'
import type { TalosVersionSpec } from '@/api/omni/specs/omni.pb'
import type { QuirksSpec } from '@/api/omni/specs/virtual.pb'
import {
DefaultNamespace,
DefaultTalosVersion,
QuirksType,
TalosVersionType,
VirtualNamespace,
} from '@/api/resources'
import CodeBlock from '@/components/CodeBlock/CodeBlock.vue'
import Modal from '@/components/Modals/Modal.vue'
import TSelectList from '@/components/SelectList/TSelectList.vue'
import TSpinner from '@/components/Spinner/TSpinner.vue'
import { downloadFile, getDocsLink, getPlatform } from '@/methods'
import TAlert from '@/components/TAlert.vue'
import { getDocsLink, getPlatform } from '@/methods'
import { useResourceList } from '@/methods/useResourceList'
import { useResourceWatch } from '@/methods/useResourceWatch'
import { useTalosctlDownloads } from '@/methods/useTalosctlDownloads'
const open = defineModel<boolean>('open', { default: false })
const platform = computedAsync(getPlatform)
const { downloads: availableVersions, defaultVersion } = useTalosctlDownloads()
const selectedVersion = ref<string>()
const selectedBinary = ref<string>()
const defaultPlatform = computed(() => {
if (!defaultVersion.value || !platform.value) return
const { data: quirks } = useResourceList<QuirksSpec>(() => ({
skip: !open.value,
runtime: Runtime.Omni,
resource: {
type: QuirksType,
namespace: VirtualNamespace,
},
}))
const {
data: versions,
loading: versionsLoading,
err: versionsErr,
} = useResourceWatch<TalosVersionSpec>(() => ({
skip: !open.value,
runtime: Runtime.Omni,
resource: {
type: TalosVersionType,
namespace: DefaultNamespace,
},
}))
const {
data: binaries,
loading: binariesLoading,
err: binariesErr,
} = useTalosctlDownloads(selectedVersion, () => ({ skip: !open.value }))
function getBinaryNameFromURL(url: string) {
return new URL(url).pathname.split('/').pop()
}
const versionsList = computed(() =>
versions.value
.filter(
(v) =>
!v.spec.deprecated &&
quirks.value?.find((q) => q.metadata.id === v.spec.version)?.spec.supports_factory_talosctl,
)
.map((v) => v.spec.version!)
.sort(compare),
)
const binariesList = computed(() =>
binaries.value.map((b) => ({
label: getBinaryNameFromURL(b) ?? b,
value: b,
})),
)
watchEffect(() => {
if (!open.value) {
selectedVersion.value = undefined
selectedBinary.value = undefined
}
const selected = toValue(selectedBinary.value)
if (selected && !binariesList.value.some((b) => b.value === selected)) {
selectedBinary.value = binariesList.value.find(
(b) => getBinaryNameFromURL(b.value) === getBinaryNameFromURL(selected),
)?.value
}
})
const defaultBinary = computed(() => {
if (!binaries.value || !platform.value) return
const [os, arch] = platform.value
const assets = availableVersions.value?.get(defaultVersion.value)
const defaultAsset = assets?.find((item) => item.url.endsWith('linux-amd64'))
const preferredAsset = assets?.find((item) => item.url.endsWith(`${os}-${arch}`))
const ext = os === 'windows' ? '.exe' : ''
return (preferredAsset ?? defaultAsset)?.name
})
const defaultAsset = binaries.value.find((item) => item.endsWith('linux-amd64'))
const preferredAsset = binaries.value.find((item) => item.endsWith(`${os}-${arch}${ext}`))
const selectedVersion = ref<string>()
const selectedPlatform = ref<string>()
const download = () => {
open.value = false
if (!selectedVersion.value) return
const link = availableVersions.value
?.get(selectedVersion.value)
?.find((item) => item.name === selectedPlatform.value)
if (!link) {
return
}
downloadFile(link.url)
}
const versionBinaries = computed<string[]>(() => {
if (!selectedVersion.value) return []
return availableVersions.value?.get(selectedVersion.value)?.map((item) => item.name) ?? []
return preferredAsset ?? defaultAsset
})
</script>
<template>
<Modal v-model:open="open" title="Download Talosctl" action-label="Download" @confirm="download">
<Modal
v-model:open="open"
title="Download Talosctl"
action-label="Download"
:action-disabled="!selectedBinary"
:action-href="selectedBinary ?? ''"
:loading="binariesLoading"
@confirm="open = false"
>
<template #description>
<code>talosctl</code>
can be used to access cluster nodes using Talos machine API. Read the
@ -81,26 +145,35 @@ const versionBinaries = computed<string[]>(() => {
<span class="mb-2 text-xs text-naturals-n14">Manual installation</span>
<div class="mb-5 flex flex-wrap gap-4">
<div v-if="availableVersions && platform" class="flex flex-wrap gap-4">
<TSelectList
v-model="selectedVersion"
title="version"
:default-value="defaultVersion"
:values="Array.from(availableVersions.keys())"
searcheable
/>
<TSelectList
v-model="selectedPlatform"
title="talosctl"
:default-value="defaultPlatform"
:values="versionBinaries"
searcheable
/>
</div>
<div v-else>
<TSpinner class="h-6 w-6" />
</div>
<TAlert v-if="versionsErr || binariesErr" title="Failed to get talosctl versions" type="error">
{{ versionsErr }}
{{ binariesErr }}
</TAlert>
<TAlert
v-else-if="!binariesLoading && !binariesList.length"
type="warn"
title="No binaries found"
>
No talosctl binaries were found for version {{ selectedVersion }}
</TAlert>
<div class="mt-2 mb-5 flex flex-wrap gap-4">
<TSelectList
v-if="!versionsLoading && !versionsErr"
v-model="selectedVersion"
title="Talos version"
:default-value="DefaultTalosVersion"
:values="versionsList"
/>
<TSelectList
v-if="binariesList.length"
v-model="selectedBinary"
title="Platform"
:default-value="defaultBinary"
:values="binariesList"
/>
</div>
<p class="flex text-xs">

View File

@ -13,12 +13,14 @@ import { Runtime } from '@/api/common/omni.pb'
import {
type PlatformConfigSpec,
PlatformConfigSpecBootMethod,
type QuirksSpec,
type SBCConfigSpec,
} from '@/api/omni/specs/virtual.pb'
import {
CloudPlatformConfigType,
MetalPlatformConfigType,
PlatformMetalID,
QuirksType,
SBCConfigType,
VirtualNamespace,
} from '@/api/resources'
@ -48,8 +50,17 @@ const formState = defineModel<FormState>({ required: true })
const resolvedTalosVersion = computed(() => resolveTalosVersion(formState.value.talosVersion!))
const supportsUnifiedInstaller = computed(() => gte(resolvedTalosVersion.value, '1.10.0'))
const talosctlAvailable = computed(() => gte(resolvedTalosVersion.value, '1.11.0-alpha.3'))
const { data: quirks } = useResourceGet<QuirksSpec>(() => ({
runtime: Runtime.Omni,
resource: {
namespace: VirtualNamespace,
type: QuirksType,
id: resolvedTalosVersion.value,
},
}))
const supportsUnifiedInstaller = computed(() => quirks.value?.spec.supports_unified_installer)
const talosctlAvailable = computed(() => quirks.value?.spec.supports_factory_talosctl)
const { data: features } = useFeatures()
const imageDownloadDialog = useTemplateRef<HTMLDialogElement>('downloadImageDialog')
@ -72,11 +83,7 @@ async function downloadImage(url: string) {
useEventListener(imageDownloadDialog, 'close', abortImageDownload)
const { downloads } = useTalosctlDownloads()
const talosctlPaths = computed(
() => downloads.value?.get(`v${resolvedTalosVersion.value}`)?.map((v) => v.url) ?? [],
)
const { data: talosctlPaths } = useTalosctlDownloads(() => resolvedTalosVersion.value)
const { data: selectedCloudProvider } = useResourceGet<PlatformConfigSpec>(() => ({
skip: formState.value.hardwareType !== 'cloud',

9
go.mod
View File

@ -74,12 +74,12 @@ require (
github.com/siderolabs/go-retry v0.3.3
github.com/siderolabs/go-talos-support v0.2.1
github.com/siderolabs/grpc-proxy v0.5.2
github.com/siderolabs/image-factory v1.1.0
github.com/siderolabs/image-factory v1.2.0
github.com/siderolabs/kms-client v0.2.0
github.com/siderolabs/omni/client v1.6.5
github.com/siderolabs/proto-codec v0.1.4
github.com/siderolabs/siderolink v0.3.16
github.com/siderolabs/talos/pkg/machinery v1.13.0-rc.0
github.com/siderolabs/talos/pkg/machinery v1.13.0
github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
@ -153,7 +153,7 @@ require (
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/creack/pty v1.1.21 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/docker/cli v29.4.0+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.5 // indirect
@ -181,6 +181,8 @@ require (
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 // indirect
github.com/go-openapi/testify/v2 v2.4.1 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
@ -189,6 +191,7 @@ require (
github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.28.0 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/gosuri/uilive v0.0.4 // indirect

24
go.sum
View File

@ -128,8 +128,8 @@ github.com/cosi-project/state-sqlite v0.4.0/go.mod h1:V20oy2Sfxla0zZ+SJSgjV20feg
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -220,10 +220,10 @@ github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzz
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q=
github.com/go-openapi/testify/v2 v2.4.1 h1:zB34HDKj4tHwyUQHrUkpV0Q0iXQ6dUCOQtIqn8hE6Iw=
github.com/go-openapi/testify/v2 v2.4.1/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
@ -260,8 +260,8 @@ github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfM
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
@ -478,8 +478,8 @@ github.com/siderolabs/go-talos-support v0.2.1 h1:QVFhcmGw1ZTTXEtukBgrdjNxYbsPAOT
github.com/siderolabs/go-talos-support v0.2.1/go.mod h1:tfwP9mpPdmqLU8DuCDgcAd0ficLlJuB/XMtrIV8g+20=
github.com/siderolabs/grpc-proxy v0.5.2 h1:o34tS02IxbX3qeXe0MM99zE2XhfWHuvdBtSWWRD9HNI=
github.com/siderolabs/grpc-proxy v0.5.2/go.mod h1:ygf+gqFxWdymAXuP4qMHTa+j6SvasdcFg3XkhpvUvDw=
github.com/siderolabs/image-factory v1.1.0 h1:M5OZXPKQtBlUbzzH9PSP+ydp75S3mdYyiLXo7BoX9mc=
github.com/siderolabs/image-factory v1.1.0/go.mod h1:cequkUpOoM+D1fA5kQrs573aLFwoqTPMAAeA1N1poog=
github.com/siderolabs/image-factory v1.2.0 h1:5/VbxTemmzBM6QFuh0VenMr9nZCblGp9JmrWeXQTwbI=
github.com/siderolabs/image-factory v1.2.0/go.mod h1:nAE1u+vYMK711Ktt8RshYW6S2zWNQ8XUAG7lMWeIZ7I=
github.com/siderolabs/kms-client v0.2.0 h1:8RniCStUI75RTZO8qkhHOSVOnEU1AvvsKqJ7FqW/8NA=
github.com/siderolabs/kms-client v0.2.0/go.mod h1:qq6dwcLPO0gaUyfkrhWi/37g/ZyZJzOHzvHrilLz48E=
github.com/siderolabs/net v0.4.0 h1:1bOgVay/ijPkJz4qct98nHsiB/ysLQU0KLoBC4qLm7I=
@ -490,8 +490,8 @@ github.com/siderolabs/protoenc v0.2.4 h1:D3Fpn2nQSQOhl8ZlAxijZAf7K6F8CM1uZq0afIG
github.com/siderolabs/protoenc v0.2.4/go.mod h1:i5XLHjfv5vyi7LhQrSEo19HCA+lYtDd7CWxsoWp9XE8=
github.com/siderolabs/siderolink v0.3.16 h1:DZzf3rzE/5hm4s9yfaYnoR2cy0od+oNKMx0GEpCxrfw=
github.com/siderolabs/siderolink v0.3.16/go.mod h1:/05gMCYn81gCRZcmWlDbKwuy2800so1Mwd1CP1h3m4g=
github.com/siderolabs/talos/pkg/machinery v1.13.0-rc.0 h1:sM0w/8BwBy6dVOu4XLW8m9xhA2iMmrmME3zpyFDHdwc=
github.com/siderolabs/talos/pkg/machinery v1.13.0-rc.0/go.mod h1:70Up2PI+g6wxW4rJ8AIlZO0MfQ8gglyfqsyBv0PdnSo=
github.com/siderolabs/talos/pkg/machinery v1.13.0 h1:nNfAUqgD/yOb4RZAc3xrQXKYllIK36RPaJacNQQC3TI=
github.com/siderolabs/talos/pkg/machinery v1.13.0/go.mod h1:70Up2PI+g6wxW4rJ8AIlZO0MfQ8gglyfqsyBv0PdnSo=
github.com/siderolabs/tcpproxy v0.1.0 h1:IbkS9vRhjMOscc1US3M5P1RnsGKFgB6U5IzUk+4WkKA=
github.com/siderolabs/tcpproxy v0.1.0/go.mod h1:onn6CPPj/w1UNqQ0U97oRPF0CqbrgEApYCw4P9IiCW8=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=

View File

@ -509,6 +509,7 @@ func filterAccess(ctx context.Context, access state.Access) error {
virtual.AdvertisedEndpointsType,
virtual.CurrentUserType,
virtual.ClusterPermissionsType,
virtual.QuirksType,
virtual.PermissionsType:
// allow access with just valid signature
_, err = auth.CheckGRPC(ctx, auth.WithValidSignature(true))
@ -656,6 +657,7 @@ func filterAccessByType(access state.Access) error {
virtual.PermissionsType,
virtual.KubernetesUsageType,
virtual.LabelsCompletionType,
virtual.QuirksType,
virtual.ClusterPermissionsType:
// allow read access only. these resources are either managed by controllers or plugins (e.g., infra provider plugins)
if access.Verb.Readonly() {

View File

@ -18,12 +18,14 @@ import (
"github.com/cosi-project/runtime/pkg/resource"
"github.com/cosi-project/runtime/pkg/safe"
"github.com/cosi-project/runtime/pkg/state"
"github.com/siderolabs/talos/pkg/machinery/imager/quirks"
"github.com/stripe/stripe-go/v85"
"go.uber.org/zap"
"github.com/siderolabs/omni/client/api/omni/specs"
"github.com/siderolabs/omni/client/pkg/omni/resources"
authres "github.com/siderolabs/omni/client/pkg/omni/resources/auth"
omniresources "github.com/siderolabs/omni/client/pkg/omni/resources/omni"
"github.com/siderolabs/omni/client/pkg/omni/resources/virtual"
stateerrors "github.com/siderolabs/omni/internal/backend/runtime/omni/virtual/pkg/errors"
"github.com/siderolabs/omni/internal/backend/runtime/omni/virtual/pkg/factory/configs"
@ -126,6 +128,8 @@ func (v *State) Get(ctx context.Context, ptr resource.Pointer, opts ...state.Get
return v.advertisedEndpoints(ctx, ptr)
case virtual.SupportType:
return v.support(ctx, ptr)
case virtual.QuirksType:
return v.quirks(ctx, ptr)
default:
return nil, stateerrors.ErrUnsupported(fmt.Errorf("unsupported resource type for get %q", ptr.Type()))
}
@ -174,6 +178,8 @@ func (v *State) List(ctx context.Context, kind resource.Kind, opts ...state.List
}
return resource.List{Items: []resource.Resource{permissions}}, nil
case virtual.QuirksType:
return v.listQuirks(ctx)
default:
return resource.List{}, stateerrors.ErrUnsupported(fmt.Errorf("unsupported resource type for list %q", kind.Type()))
}
@ -424,6 +430,45 @@ func (v *State) support(ctx context.Context, ptr resource.Pointer) (*virtual.Sup
return res, nil
}
func (v *State) listQuirks(ctx context.Context) (resource.List, error) {
list, err := v.PrimaryState.List(ctx, resource.NewMetadata(resources.DefaultNamespace, omniresources.TalosVersionType, "", resource.VersionUndefined))
if err != nil {
return resource.List{}, err
}
items := make([]resource.Resource, 0, len(list.Items))
for _, item := range list.Items {
q, err := v.quirks(ctx, item.Metadata())
if err != nil {
return resource.List{}, err
}
items = append(items, q)
}
return resource.List{Items: items}, nil
}
func (v *State) quirks(_ context.Context, ptr resource.Pointer) (*virtual.Quirks, error) {
res := virtual.NewQuirks(ptr.ID())
version, err := resource.ParseVersion("1")
if err != nil {
return nil, err
}
q := quirks.New(ptr.ID())
res.Metadata().SetVersion(version)
res.TypedSpec().Value = &specs.QuirksSpec{
SupportsUnifiedInstaller: q.SupportsUnifiedInstaller(),
SupportsFactoryTalosctl: q.SupportsFactoryTalosctlDownload(),
}
return res, nil
}
func fetchSupportEnabled(ctx context.Context, stripeClient *stripe.Client, stripeSubscriptionItemID string, eligibleProducts []string) (bool, error) {
params := &stripe.SubscriptionItemRetrieveParams{}
params.AddExpand("price.product")

View File

@ -21,10 +21,10 @@ import (
"os"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/blang/semver/v4"
coidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/cosi-project/runtime/api/v1alpha1"
"github.com/cosi-project/runtime/pkg/resource"
@ -351,6 +351,7 @@ func (s *Server) makeMux(oidcProvider *oidc.Provider) (*http.ServeMux, error) {
samlHandler,
s.state,
s.omniRuntime,
s.imageFactoryClient,
s.logger,
s.cfg,
)
@ -857,6 +858,7 @@ func makeMux(
samlHandler *samlsp.Middleware,
state *omni.State,
omniRuntime *omni.Runtime,
imageFactoryClient *imagefactory.Client,
logger *zap.Logger,
cfg *config.Params,
) (*http.ServeMux, error) {
@ -895,14 +897,14 @@ func makeMux(
return nil, err
}
talosctlHandler, err := makeTalosctlHandler(state.Default(), logger)
talosctlHandler, err := makeTalosctlHandler(imageFactoryClient, logger)
if err != nil {
return nil, err
}
muxHandle("/exposed/service", workloadProxyRedirect, "exposed-service-redirect")
muxHandle("/omnictl/", http.StripPrefix("/omnictl/", omnictlHndlr), "files")
muxHandle("/talosctl/downloads", talosctlHandler, "talosctl-downloads")
muxHandle("/talosctl/downloads/{version}", talosctlHandler, "talosctl-downloads")
// actually enabled only in debug build
muxHandle("/debug/", debug.NewHandler(omniRuntime.GetCOSIRuntime(), state.Default()), "debug")
@ -1249,14 +1251,14 @@ func runPprofServer(ctx context.Context, bindAddress string, l *zap.Logger) erro
}
//nolint:unparam
func makeTalosctlHandler(state state.State, logger *zap.Logger) (http.Handler, error) {
func makeTalosctlHandler(imageFactoryClient *imagefactory.Client, logger *zap.Logger) (http.Handler, error) {
// The list of versions does not update very often, so we can cache it.
cacher := cache.Value[releaseData]{Duration: time.Hour}
var cacherMap sync.Map
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type result struct {
ReleaseData *releaseData `json:"release_data,omitempty"`
Status string `json:"status"`
Status string `json:"status"`
Downloads []string `json:"downloads,omitempty"`
}
writeResult := func(a any, code int) {
@ -1268,9 +1270,21 @@ func makeTalosctlHandler(state state.State, logger *zap.Logger) (http.Handler, e
}
}
talosVersion := r.PathValue("version")
actual, _ := cacherMap.LoadOrStore(talosVersion, &cache.Value[[]string]{Duration: time.Hour})
cacher, ok := actual.(*cache.Value[[]string])
if !ok {
logger.Error("failed to load version cache")
writeResult(result{Status: "failed to load version cache"}, http.StatusInternalServerError)
return
}
ctx := actor.MarkContextAsInternalActor(r.Context())
data, err := cacher.GetOrUpdate(func() (releaseData, error) { return getReleaseData(ctx, state) })
data, err := cacher.GetOrUpdate(func() ([]string, error) { return imageFactoryClient.TalosctlList(ctx, talosVersion) })
if err != nil {
logger.Error("failed to get latest talosctl release", zap.Error(err))
writeResult(result{Status: "failed to get latest talosctl release"}, http.StatusInternalServerError)
@ -1279,140 +1293,12 @@ func makeTalosctlHandler(state state.State, logger *zap.Logger) (http.Handler, e
}
writeResult(result{
ReleaseData: &data,
Status: "ok",
Status: "ok",
Downloads: data,
}, http.StatusOK)
}), nil
}
func getReleaseData(ctx context.Context, state state.State) (releaseData, error) {
all, err := safe.StateListAll[*omnires.TalosVersion](ctx, state)
if err != nil {
return releaseData{}, fmt.Errorf("failed to list all talos versions: %w", err)
}
if all.Len() == 0 {
return releaseData{}, errors.New("no talos versions found")
}
versionNames := make([]string, 0, all.Len())
for val := range all.All() {
version := val.TypedSpec().Value.Version
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
versionNames = append(versionNames, version)
}
releases, err := getGithubReleases(versionNames...)
if err != nil {
return releaseData{}, err
}
return releases, nil
}
func getGithubReleases(tags ...string) (releaseData, error) {
if len(tags) == 0 {
return releaseData{}, errors.New("no tags provided")
}
versions := make(map[string][]talosctlAsset, len(tags))
for _, tag := range tags {
assets := make([]talosctlAsset, 0, len(assetsData))
for _, asset := range assetsData {
if asset.minTalosVersion != "" {
v, err := semver.ParseTolerant(tag)
if err != nil {
continue
}
if v.LT(semver.MustParse(asset.minTalosVersion)) {
continue
}
}
assets = append(assets, talosctlAsset{
Name: asset.name,
URL: fmt.Sprintf("https://github.com/siderolabs/talos/releases/download/%s/%s", tag, asset.urlPart),
})
}
versions[tag] = assets
}
return releaseData{
AvailableVersions: versions,
DefaultVersion: tags[len(tags)-1],
}, nil
}
type releaseData struct {
AvailableVersions map[string][]talosctlAsset `json:"available_versions"`
DefaultVersion string `json:"default_version,omitempty"`
}
type talosctlAsset struct {
Name string `json:"name"`
URL string `json:"url"`
}
var assetsData = []struct {
name string
urlPart string
minTalosVersion string
}{
{
"Apple",
"talosctl-darwin-amd64",
"",
},
{
"Apple Silicon",
"talosctl-darwin-arm64",
"",
},
{
"FreeBSD",
"talosctl-freebsd-amd64",
"",
},
{
"FreeBSD ARM64",
"talosctl-freebsd-arm64",
"",
},
{
"Linux",
"talosctl-linux-amd64",
"",
},
{
"Linux ARM",
"talosctl-linux-armv7",
"",
},
{
"Linux ARM64",
"talosctl-linux-arm64",
"",
},
{
"Windows",
"talosctl-windows-amd64.exe",
"",
},
{
"Windows ARM64",
"talosctl-windows-arm64.exe",
"1.9.0",
},
}
// Auditor is a common interface for audit log.
type Auditor interface {
RunCleanup(context.Context) error

View File

@ -1234,6 +1234,11 @@ func AssertResourceAuthz(rootCtx context.Context, rootCli *client.Client, client
resource: virtual.NewSupport(),
allowedVerbSet: readOnlyVerbSet,
},
{
resource: virtual.NewQuirks(uuid.New().String()),
allowedVerbSet: readOnlyVerbSet,
isSignatureSufficient: true,
},
{
resource: omni.NewEtcdBackupStatus(uuid.New().String()),
allowedVerbSet: readOnlyVerbSet,