diff --git a/cmd/unikraft/instances_test.go b/cmd/unikraft/instances_test.go index 7c392932..1ce3f56e 100644 --- a/cmd/unikraft/instances_test.go +++ b/cmd/unikraft/instances_test.go @@ -200,7 +200,7 @@ func instancesTests(t *testing.T, r *testRunner) { }) }) - t.Run("instances/volume", func(t *testing.T) { + t.Run("volume", func(t *testing.T) { r. online(). withCleaners(instanceCleaners). @@ -230,7 +230,7 @@ func instancesTests(t *testing.T, r *testRunner) { }) }) - t.Run("instances/volume-inline", func(t *testing.T) { + t.Run("volume-inline", func(t *testing.T) { r. online(). withCleaners(instanceCleaners). @@ -253,7 +253,7 @@ func instancesTests(t *testing.T, r *testRunner) { }) }) - t.Run("instances/autostart", func(t *testing.T) { + t.Run("autostart", func(t *testing.T) { r. online(). withCleaners(instanceCleaners). @@ -306,7 +306,7 @@ func instancesTests(t *testing.T, r *testRunner) { }) }) - t.Run("instances/add-domain", func(t *testing.T) { + t.Run("add-domain", func(t *testing.T) { r. online(). withCleaners(instanceCleaners). diff --git a/cmd/unikraft/testdata/TestGolden/instances/instances/add-domain b/cmd/unikraft/testdata/TestGolden/instances/add-domain similarity index 100% rename from cmd/unikraft/testdata/TestGolden/instances/instances/add-domain rename to cmd/unikraft/testdata/TestGolden/instances/add-domain index 33f51db2..73227011 100644 --- a/cmd/unikraft/testdata/TestGolden/instances/instances/add-domain +++ b/cmd/unikraft/testdata/TestGolden/instances/add-domain @@ -10,8 +10,8 @@ stdout: memory: 128MiB vcpus: 1 service: - uuid: 12345678-1234-1234-1234-123456789abc name: + uuid: 12345678-1234-1234-1234-123456789abc domains: - fqdn: .unikraft.internal networks: @@ -40,8 +40,8 @@ stdout: memory: 128MiB vcpus: 1 service: - uuid: 12345678-1234-1234-1234-123456789abc name: + uuid: 12345678-1234-1234-1234-123456789abc domains: - fqdn: .unikraft.internal - fqdn: .unikraft.internal diff --git a/cmd/unikraft/testdata/TestGolden/instances/instances/autostart b/cmd/unikraft/testdata/TestGolden/instances/autostart similarity index 100% rename from cmd/unikraft/testdata/TestGolden/instances/instances/autostart rename to cmd/unikraft/testdata/TestGolden/instances/autostart diff --git a/cmd/unikraft/testdata/TestGolden/instances/connect b/cmd/unikraft/testdata/TestGolden/instances/connect index 14bb3e1b..0251d87e 100644 --- a/cmd/unikraft/testdata/TestGolden/instances/connect +++ b/cmd/unikraft/testdata/TestGolden/instances/connect @@ -15,8 +15,8 @@ stdout: memory: 128MiB vcpus: 1 service: - uuid: 12345678-1234-1234-1234-123456789abc name: + uuid: 12345678-1234-1234-1234-123456789abc domains: - fqdn: .unikraft.internal networks: @@ -45,8 +45,8 @@ stdout: memory: 128MiB vcpus: 1 service: - uuid: 12345678-1234-1234-1234-123456789abc name: + uuid: 12345678-1234-1234-1234-123456789abc domains: - fqdn: .unikraft.internal networks: diff --git a/cmd/unikraft/testdata/TestGolden/instances/help b/cmd/unikraft/testdata/TestGolden/instances/help index e37c01dd..3aa809f8 100644 --- a/cmd/unikraft/testdata/TestGolden/instances/help +++ b/cmd/unikraft/testdata/TestGolden/instances/help @@ -43,7 +43,7 @@ stdout: image runtime, runtime.args, runtime.env resources, resources.memory, resources.vcpus - service, service.uuid, service.name, service.services, service.domains, + service, service.name, service.uuid, service.services, service.domains, service.domains.*, service.domains.*.fqdn, service.domains.*.certificate, service.domains.*.certificate.name, service.domains.*.certificate.uuid, service.soft-limit, service.hard-limit @@ -103,7 +103,7 @@ stdout: image runtime, runtime.args, runtime.env resources, resources.memory, resources.vcpus - service, service.uuid, service.name, service.services, service.domains, + service, service.name, service.uuid, service.services, service.domains, service.domains.*, service.domains.*.fqdn, service.domains.*.certificate, service.domains.*.certificate.name, service.domains.*.certificate.uuid, service.soft-limit, service.hard-limit @@ -171,7 +171,7 @@ stdout: image runtime, runtime.args, runtime.env resources, resources.memory, resources.vcpus - service, service.uuid, service.name, service.services, service.domains, + service, service.name, service.uuid, service.services, service.domains, service.domains.*, service.domains.*.fqdn, service.domains.*.certificate, service.domains.*.certificate.name, service.domains.*.certificate.uuid, service.soft-limit, service.hard-limit @@ -239,7 +239,7 @@ stdout: image runtime, runtime.args, runtime.env resources, resources.memory, resources.vcpus - service, service.uuid, service.name, service.services, service.domains, + service, service.name, service.uuid, service.services, service.domains, service.domains.*, service.domains.*.fqdn, service.domains.*.certificate, service.domains.*.certificate.name, service.domains.*.certificate.uuid, service.soft-limit, service.hard-limit @@ -321,7 +321,7 @@ stdout: image runtime, runtime.args, runtime.env resources, resources.memory, resources.vcpus - service, service.uuid, service.name, service.services, service.domains, + service, service.name, service.uuid, service.services, service.domains, service.domains.*, service.domains.*.fqdn, service.domains.*.certificate, service.domains.*.certificate.name, service.domains.*.certificate.uuid, service.soft-limit, service.hard-limit @@ -444,7 +444,7 @@ stdout: image runtime, runtime.args, runtime.env resources, resources.memory, resources.vcpus - service, service.uuid, service.name, service.services, service.domains, + service, service.name, service.uuid, service.services, service.domains, service.domains.*, service.domains.*.fqdn, service.domains.*.certificate, service.domains.*.certificate.name, service.domains.*.certificate.uuid, service.soft-limit, service.hard-limit @@ -543,7 +543,7 @@ stdout: image runtime, runtime.args, runtime.env resources, resources.memory, resources.vcpus - service, service.uuid, service.name, service.services, service.domains, + service, service.name, service.uuid, service.services, service.domains, service.domains.*, service.domains.*.fqdn, service.domains.*.certificate, service.domains.*.certificate.name, service.domains.*.certificate.uuid, service.soft-limit, service.hard-limit diff --git a/cmd/unikraft/testdata/TestGolden/instances/instances/volume b/cmd/unikraft/testdata/TestGolden/instances/volume similarity index 100% rename from cmd/unikraft/testdata/TestGolden/instances/instances/volume rename to cmd/unikraft/testdata/TestGolden/instances/volume diff --git a/cmd/unikraft/testdata/TestGolden/instances/instances/volume-inline b/cmd/unikraft/testdata/TestGolden/instances/volume-inline similarity index 100% rename from cmd/unikraft/testdata/TestGolden/instances/instances/volume-inline rename to cmd/unikraft/testdata/TestGolden/instances/volume-inline diff --git a/cmd/unikraft/testdata/TestGolden/volumes/import/serve b/cmd/unikraft/testdata/TestGolden/volumes/import/serve index 5eb80b0e..c28adfc5 100644 --- a/cmd/unikraft/testdata/TestGolden/volumes/import/serve +++ b/cmd/unikraft/testdata/TestGolden/volumes/import/serve @@ -21,8 +21,8 @@ stdout: memory: 256MiB vcpus: 1 service: - uuid: 12345678-1234-1234-1234-123456789abc name: + uuid: 12345678-1234-1234-1234-123456789abc domains: - fqdn: .unikraft.internal volumes: @@ -48,8 +48,8 @@ stdout: memory: 256MiB vcpus: 1 service: - uuid: 12345678-1234-1234-1234-123456789abc name: + uuid: 12345678-1234-1234-1234-123456789abc domains: - fqdn: .unikraft.internal volumes: diff --git a/internal/cmd/certificates.go b/internal/cmd/certificates.go index db732f33..3818b3ac 100644 --- a/internal/cmd/certificates.go +++ b/internal/cmd/certificates.go @@ -60,9 +60,9 @@ func (c *CertificateCreateCmd) Run(ctx context.Context, stdio config.Stdio, sand } type Certificate struct { - MetroName string `mirror:"metro.name" field:"metro,short" create:"set,required"` - Name string `mirror:"certificate.name" field:",short" create:"set"` - UUID string `mirror:"certificate.uuid" field:",long"` + MetroName LinkName[Metro] `mirror:"metro.name" field:"metro,short" create:"set,required"` + Name string `mirror:"certificate.name" field:",short" create:"set"` + UUID string `mirror:"certificate.uuid" field:",long"` CommonName string `mirror:"certificate.common_name" field:",short"` Subject string `mirror:"certificate.subject" field:",long"` @@ -103,24 +103,8 @@ func (c Certificate) Raw() any { } func (c Certificate) Fields(ctx context.Context) ([]resource.Field, error) { - c.MetroName = defaultMetro(ctx, c.MetroName) - result, err := resource.FieldsFromStruct(c) - if err != nil { - return nil, err - } - - for key, field := range resource.IterFields(result) { - if key.String() == "metro" { - if c.MetroName != "" { - field.Links = append(field.Links, resource.Link{ - Type: "metro", - Key: c.MetroName, - }) - } - } - } - - return result, nil + c.MetroName = LinkName[Metro](defaultMetro(ctx, string(c.MetroName))) + return resource.FieldsFromStruct(c) } func (Certificate) List(ctx context.Context) ([]resource.Resource, error) { @@ -245,7 +229,7 @@ func (Certificate) Create(ctx context.Context, fields []resource.Field) ([]resou name := field.Create.Set.(string) req.Name = &name case "metro": - metro = field.Create.Set.(string) + metro = string(field.Create.Set.(LinkName[Metro])) case "cn": req.Cn = new(field.Create.Set.(string)) //nolint:staticcheck // CommonName not on stable yet case "chain": diff --git a/internal/cmd/instance_templates.go b/internal/cmd/instance_templates.go index 1ab98389..1539f097 100644 --- a/internal/cmd/instance_templates.go +++ b/internal/cmd/instance_templates.go @@ -74,9 +74,9 @@ func (c *InstanceTemplateEditCmd) Run(ctx context.Context, stdio config.Stdio, s } type InstanceTemplate struct { - MetroName string `mirror:"metro.name" field:"metro,short"` - Name string `mirror:"instance.name" field:",short"` - UUID string `mirror:"instance.uuid" field:",long"` + MetroName LinkName[Metro] `mirror:"metro.name" field:"metro,short"` + Name string `mirror:"instance.name" field:",short"` + UUID string `mirror:"instance.uuid" field:",long"` Tags []string `mirror:"instance.tags" edit:"set,add,del"` DeleteLock bool `mirror:"instance.delete_lock" field:"delete-lock,hidden" edit:"set"` @@ -131,49 +131,7 @@ func (i InstanceTemplate) Raw() any { } func (i InstanceTemplate) Fields(ctx context.Context) ([]resource.Field, error) { - result, err := resource.FieldsFromStruct(i) - if err != nil { - return nil, err - } - - for key, field := range resource.IterFields(result) { - switch { - case key.String() == "metro": - if i.MetroName != "" { - field.Links = append(field.Links, resource.Link{ - Type: "metro", - Key: i.MetroName, - }) - } - case key.MatchesString("volumes.*"): - if i.Metro == nil { - break - } - nameField, _ := field.Get("name") - uuidField, _ := field.Get("uuid") - name, _ := nameField.Value.(string) - uuid, _ := uuidField.Value.(string) - if name != "" || uuid != "" { - field.Links = append(field.Links, resource.Link{ - Type: "volume", - Key: multimetro.Key{ - Metro: i.Metro.Name, - Name: name, - UUID: uuid, - }.String(), - }) - } - case key.String() == "image": - if i.Image.Reference != nil { - field.Links = append(field.Links, resource.Link{ - Type: "image", - Key: i.Image.Reference.String(), - }) - } - } - } - - return result, nil + return resource.FieldsFromStruct(i) } func (InstanceTemplate) List(ctx context.Context) ([]resource.Resource, error) { diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index 890c2d3d..f01e621a 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -125,9 +125,9 @@ func (c *InstanceEditCmd) Run(ctx context.Context, stdio config.Stdio, sandbox * } type Instance struct { - MetroName string `mirror:"metro.name" field:"metro,short" create:"set,required"` - Name string `mirror:"instance.name" field:",short" create:"set"` - UUID string `mirror:"instance.uuid" field:",long"` + MetroName LinkName[Metro] `mirror:"metro.name" field:"metro,short" create:"set,required"` + Name string `mirror:"instance.name" field:",short" create:"set"` + UUID string `mirror:"instance.uuid" field:",long"` Tags []string `mirror:"instance.tags"` @@ -197,9 +197,7 @@ type Instance struct { } type InstanceService struct { - Metro string `field:"-"` - UUID string `mirror:"uuid" field:",long"` - Name string `mirror:"name" field:",long"` + Link[ServiceGroup] Services []*Service `mirror:"services" field:",invisible,valueless" create:"set"` Domains []Domain `mirror:"domains" field:",short,embed" create:"set"` SoftLimit uint32 `field:"soft-limit,invisible,valueless" create:"set"` @@ -240,8 +238,7 @@ func (i *InstanceService) UnmarshalJSON(data []byte) error { } type InstanceVolume struct { - UUID string `name:"uuid" mirror:"uuid" json:"uuid,omitempty" field:",long"` - Name string `name:"name" mirror:"name" json:"name,omitempty" field:",long"` + Link[Volume] At string `name:"at" mirror:"at" json:"at" field:",long"` Readonly bool `name:"readonly" mirror:"readonly" json:"readonly,omitempty" field:",long"` @@ -453,61 +450,15 @@ func (i Instance) Raw() any { } func (i Instance) Fields(ctx context.Context) ([]resource.Field, error) { - i.MetroName = defaultMetro(ctx, i.MetroName) + i.MetroName = LinkName[Metro](defaultMetro(ctx, string(i.MetroName))) result, err := resource.FieldsFromStruct(i) if err != nil { return nil, err } for key, field := range resource.IterFields(result) { - switch { - case key.String() == "name": + if key.String() == "name" { field.Hyperlink = i.hyperlink() - case key.String() == "metro": - if i.MetroName != "" { - field.Links = append(field.Links, resource.Link{ - Type: "metro", - Key: i.MetroName, - }) - } - case key.String() == "service": - nameField, _ := field.Get("name") - uuidField, _ := field.Get("uuid") - name, _ := nameField.Value.(string) - uuid, _ := uuidField.Value.(string) - if name != "" || uuid != "" { - field.Links = append(field.Links, resource.Link{ - Type: "service", - Key: multimetro.Key{ - Metro: i.Metro.Name, - Name: name, - UUID: uuid, - }.String(), - }) - } - case key.MatchesString("volumes.*"): - // Add link to volume resource - nameField, _ := field.Get("name") - uuidField, _ := field.Get("uuid") - name, _ := nameField.Value.(string) - uuid, _ := uuidField.Value.(string) - if name != "" || uuid != "" { - field.Links = append(field.Links, resource.Link{ - Type: "volume", - Key: multimetro.Key{ - Metro: i.Metro.Name, - Name: name, - UUID: uuid, - }.String(), - }) - } - case key.String() == "image": - if i.Image.Reference != nil { - field.Links = append(field.Links, resource.Link{ - Type: "image", - Key: i.Image.Reference.String(), - }) - } } } @@ -782,7 +733,7 @@ func (Instance) Create(ctx context.Context, fields []resource.Field) ([]resource name := field.Create.Set.(string) req.Name = &name case "metro": - metro = field.Create.Set.(string) + metro = string(field.Create.Set.(LinkName[Metro])) case "image": req.Image = new(field.Create.Set.(types.ImageRef[reference.Named]).Reference.String()) case "runtime.args": diff --git a/internal/cmd/link.go b/internal/cmd/link.go new file mode 100644 index 00000000..d357d895 --- /dev/null +++ b/internal/cmd/link.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package cmd + +import ( + "unikraft.com/cloud/sdk/platform/group" + + "unikraft.com/cli/internal/multimetro" + "unikraft.com/cli/internal/resource" +) + +// Link models a resource reference using group.Ref-compatible fields. +type Link[T resource.Resource] struct { + Metro string `name:"metro" json:"metro,omitempty" mirror:"metro" field:"-"` + Name string `name:"name" json:"name,omitempty" mirror:"name" field:",long"` + UUID string `name:"uuid" json:"uuid,omitempty" mirror:"uuid" field:",long"` +} + +func (l Link[T]) Ref() group.Ref { + return group.Ref{ + Metro: l.Metro, + Name: l.Name, + UUID: l.UUID, + } +} + +func (l Link[T]) Link() (string, resource.Key) { + if l.Name == "" && l.UUID == "" { + return "", nil + } + var zero T + return zero.Type().Name, multimetro.Key{ + Metro: l.Metro, + Name: l.Name, + UUID: l.UUID, + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +// When parsing from text, the value is stored as the name. +func (l *Link[T]) UnmarshalText(text []byte) error { + l.Name = string(text) + return nil +} + +// LinkName models a simple name-only link. +type LinkName[T resource.Resource] string + +func (l LinkName[T]) Link() (string, resource.Key) { + if l == "" { + return "", nil + } + var zero T + return zero.Type().Name, multimetro.Key{ + Name: string(l), + } +} + +// MarshalText implements encoding.TextMarshaler. +func (l LinkName[T]) MarshalText() ([]byte, error) { + return []byte(l), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (l *LinkName[T]) UnmarshalText(text []byte) error { + *l = LinkName[T](text) + return nil +} diff --git a/internal/cmd/services.go b/internal/cmd/services.go index f3da17d4..f57764ce 100644 --- a/internal/cmd/services.go +++ b/internal/cmd/services.go @@ -88,9 +88,9 @@ func (c *ServiceEditCmd) Run(ctx context.Context, stdio config.Stdio, sandbox *r } type ServiceGroup struct { - MetroName string `mirror:"metro.name" field:"metro,short" create:"set,required"` - Name string `mirror:"service_group.name" field:",short" create:"set"` - UUID string `mirror:"service_group.uuid" field:",long"` + MetroName LinkName[Metro] `mirror:"metro.name" field:"metro,short" create:"set,required"` + Name string `mirror:"service_group.name" field:",short" create:"set"` + UUID string `mirror:"service_group.uuid" field:",long"` Persistent bool `mirror:"service_group.persistent" field:",long"` Autoscale bool `mirror:"service_group.autoscale" field:",short"` @@ -107,8 +107,7 @@ type ServiceGroup struct { Domains []Domain `mirror:"service_group.domains" field:",embed" create:"set" edit:"set,add,del"` Instances []struct { - Name string `mirror:"name" field:",long"` - UUID string `mirror:"uuid" field:",long"` + Link[Instance] } `mirror:"service_group.instances"` Services []*Service `mirror:"service_group.services" field:",embed" create:"set,required" edit:"set,add,del"` @@ -193,8 +192,7 @@ type Domain struct { Name string `name:"name" json:"name,omitempty" field:"-"` // field:"-" excludes from field system, name:"name" allows --set parsing Certificate struct { - Name string `name:"name" json:"name" mirror:"name" field:",long"` - UUID string `name:"uuid" json:"uuid" mirror:"uuid" field:",long"` + Link[Certificate] } `name:"certificate" json:"certificate,omitzero" mirror:"certificate"` } @@ -256,56 +254,8 @@ func (s ServiceGroup) Raw() any { } func (s ServiceGroup) Fields(ctx context.Context) ([]resource.Field, error) { - s.MetroName = defaultMetro(ctx, s.MetroName) - result, err := resource.FieldsFromStruct(s) - if err != nil { - return nil, err - } - - for key, field := range resource.IterFields(result) { - switch { - case key.String() == "metro": - if s.MetroName != "" { - field.Links = append(field.Links, resource.Link{ - Type: "metro", - Key: s.MetroName, - }) - } - case key.MatchesString("instances.*"): - // Add link to instance resource - nameField, _ := field.Get("name") - uuidField, _ := field.Get("uuid") - name, _ := nameField.Value.(string) - uuid, _ := uuidField.Value.(string) - if name != "" || uuid != "" { - field.Links = append(field.Links, resource.Link{ - Type: "instance", - Key: multimetro.Key{ - Metro: s.Metro.Name, - Name: name, - UUID: uuid, - }.String(), - }) - } - case key.MatchesString("domains.*.certificate"): - nameField, _ := field.Get("name") - uuidField, _ := field.Get("uuid") - name, _ := nameField.Value.(string) - uuid, _ := uuidField.Value.(string) - if name != "" || uuid != "" { - field.Links = append(field.Links, resource.Link{ - Type: "certificate", - Key: multimetro.Key{ - Metro: s.Metro.Name, - Name: name, - UUID: uuid, - }.String(), - }) - } - } - } - - return result, nil + s.MetroName = LinkName[Metro](defaultMetro(ctx, string(s.MetroName))) + return resource.FieldsFromStruct(s) } func (ServiceGroup) List(ctx context.Context) ([]resource.Resource, error) { @@ -419,7 +369,7 @@ func (ServiceGroup) Create(ctx context.Context, fields []resource.Field) ([]reso if field.Create != nil && field.Create.Set != nil { switch key.String() { case "metro": - metro = field.Create.Set.(string) + metro = string(field.Create.Set.(LinkName[Metro])) case "name": name := field.Create.Set.(string) req.Name = &name diff --git a/internal/cmd/volume_templates.go b/internal/cmd/volume_templates.go index 8d5dad61..f0888e96 100644 --- a/internal/cmd/volume_templates.go +++ b/internal/cmd/volume_templates.go @@ -73,9 +73,9 @@ func (c *VolumeTemplateEditCmd) Run(ctx context.Context, stdio config.Stdio, san } type VolumeTemplate struct { - MetroName string `mirror:"metro.name" field:"metro,short"` - Name string `mirror:"volume.name" field:",short"` - UUID string `mirror:"volume.uuid" field:",long"` + MetroName LinkName[Metro] `mirror:"metro.name" field:"metro,short"` + Name string `mirror:"volume.name" field:",short"` + UUID string `mirror:"volume.uuid" field:",long"` Tags []string `mirror:"volume.tags" edit:"set,add,del"` DeleteLock bool `mirror:"volume.delete_lock" field:"delete-lock,hidden" edit:"set"` @@ -114,24 +114,7 @@ func (v VolumeTemplate) Raw() any { } func (v VolumeTemplate) Fields(ctx context.Context) ([]resource.Field, error) { - result, err := resource.FieldsFromStruct(v) - if err != nil { - return nil, err - } - - for key, field := range resource.IterFields(result) { - switch { - case key.String() == "metro": - if v.MetroName != "" { - field.Links = append(field.Links, resource.Link{ - Type: "metro", - Key: v.MetroName, - }) - } - } - } - - return result, nil + return resource.FieldsFromStruct(v) } func (VolumeTemplate) List(ctx context.Context) ([]resource.Resource, error) { diff --git a/internal/cmd/volumes.go b/internal/cmd/volumes.go index a92a4ca7..ca5dfb00 100644 --- a/internal/cmd/volumes.go +++ b/internal/cmd/volumes.go @@ -233,9 +233,9 @@ func (c *VolumesCloneCmd) Run(ctx context.Context, stdio config.Stdio, sandbox * } type Volume struct { - MetroName string `mirror:"metro.name" field:"metro,short" create:"set,required"` - Name string `mirror:"volume.name" field:",short" create:"set"` - UUID string `mirror:"volume.uuid" field:",long"` + MetroName LinkName[Metro] `mirror:"metro.name" field:"metro,short" create:"set,required"` + Name string `mirror:"volume.name" field:",short" create:"set"` + UUID string `mirror:"volume.uuid" field:",long"` Tags []string `mirror:"volume.tags"` @@ -250,14 +250,12 @@ type Volume struct { } AttachedTo []struct { - Name string `mirror:"name" field:",long"` - UUID string `mirror:"uuid" field:",long"` + Link[Instance] } `mirror:"volume.attached_to"` MountedBy []struct { - Name string `mirror:"name" field:",long"` - UUID string `mirror:"uuid" field:",long"` - ReadOnly bool `mirror:"read_only" field:",long"` + Link[Instance] + ReadOnly bool `mirror:"read_only" field:",long"` } `mirror:"volume.mounted_by"` Volume platform.Volume `field:"-" json:"volume"` @@ -282,57 +280,8 @@ func (i Volume) Raw() any { } func (i Volume) Fields(ctx context.Context) ([]resource.Field, error) { - i.MetroName = defaultMetro(ctx, i.MetroName) - result, err := resource.FieldsFromStruct(i) - if err != nil { - return nil, err - } - - for key, field := range resource.IterFields(result) { - switch { - case key.String() == "metro": - if i.MetroName != "" { - field.Links = append(field.Links, resource.Link{ - Type: "metro", - Key: i.MetroName, - }) - } - case key.MatchesString("attached-to.*"): - // Add link to instance resource - nameField, _ := field.Get("name") - uuidField, _ := field.Get("uuid") - name, _ := nameField.Value.(string) - uuid, _ := uuidField.Value.(string) - if name != "" || uuid != "" { - field.Links = append(field.Links, resource.Link{ - Type: "instance", - Key: multimetro.Key{ - Metro: i.Metro.Name, - Name: name, - UUID: uuid, - }.String(), - }) - } - case key.MatchesString("mounted-by.*"): - // Add link to instance resource - nameField, _ := field.Get("name") - uuidField, _ := field.Get("uuid") - name, _ := nameField.Value.(string) - uuid, _ := uuidField.Value.(string) - if name != "" || uuid != "" { - field.Links = append(field.Links, resource.Link{ - Type: "instance", - Key: multimetro.Key{ - Metro: i.Metro.Name, - Name: name, - UUID: uuid, - }.String(), - }) - } - } - } - - return result, nil + i.MetroName = LinkName[Metro](defaultMetro(ctx, string(i.MetroName))) + return resource.FieldsFromStruct(i) } func (Volume) List(ctx context.Context) ([]resource.Resource, error) { @@ -461,7 +410,7 @@ func (Volume) Create(ctx context.Context, fields []resource.Field) ([]resource.R name := field.Create.Set.(string) req.Name = &name case "metro": - metro = field.Create.Set.(string) + metro = string(field.Create.Set.(LinkName[Metro])) case "size": size := field.Create.Set.(types.SizeMebibytes) sizeMb := uint64(size) diff --git a/internal/resource/resource.go b/internal/resource/resource.go index b21cca50..2e7e0145 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -7,6 +7,7 @@ package resource import ( "context" + "encoding/json" "fmt" "reflect" @@ -66,9 +67,8 @@ type DefaultResource interface { Default(ctx context.Context) (Resource, error) } -type Link struct { - Type string - Key string +type Link interface { + Link() (string, Key) } type Field struct { @@ -87,7 +87,7 @@ type Field struct { Elem *Field `json:"elem,omitempty"` // ElemMap indicates this field contains map elements, and subfields should // be rendered as key-value pairs. - ElemMap bool `json:"elemMap,omitempty"` + ElemMap bool `json:"elem_map,omitempty"` Links []Link `json:"links,omitempty"` @@ -138,6 +138,52 @@ func (f Field) Get(name string) (Field, bool) { return Field{}, false } +func (f Field) MarshalJSON() ([]byte, error) { + type linkJSON struct { + Type string `json:"type"` + Key string `json:"key"` + } + links := make([]linkJSON, 0, len(f.Links)) + for _, link := range f.Links { + if link == nil { + continue + } + linkType, linkKey := link.Link() + if linkType == "" || linkKey == nil { + continue + } + key := linkKey.String() + if key == "" { + continue + } + links = append(links, linkJSON{Type: linkType, Key: key}) + } + + return json.Marshal(struct { + Name string `json:"name"` + Value any `json:"value,omitempty"` + Subfields []Field `json:"subfields,omitempty"` + Elem *Field `json:"elem,omitempty"` + ElemMap bool `json:"elem_map,omitempty"` + Links []linkJSON `json:"links,omitempty"` + Verbosity FieldVerbosity `json:"verbosity"` + Hyperlink string `json:"hyperlink,omitempty"` + Create *Patch `json:"create,omitempty"` + Edit *Patch `json:"edit,omitempty"` + }{ + Name: f.Name, + Value: f.Value, + Subfields: f.Subfields, + Elem: f.Elem, + ElemMap: f.ElemMap, + Links: links, + Verbosity: f.Verbosity, + Hyperlink: f.Hyperlink, + Create: f.Create, + Edit: f.Edit, + }) +} + type Patch struct { Set any `json:"set,omitempty"` Add any `json:"add,omitempty"` diff --git a/internal/resource/sandbox.go b/internal/resource/sandbox.go index 0df6bbc3..f77e61db 100644 --- a/internal/resource/sandbox.go +++ b/internal/resource/sandbox.go @@ -186,24 +186,32 @@ func (s *Sandbox) add(ctx context.Context, r Resource, visited map[string]struct } for _, field := range IterFields(fields) { for _, link := range field.Links { - if link.Type == "" || link.Key == "" { + if link == nil { + continue + } + linkType, linkKey := link.Link() + if linkType == "" || linkKey == nil { + continue + } + key := linkKey.String() + if key == "" { continue } for _, r := range s.Cleanup { - if r.Type().Name != link.Type { + if r.Type().Name != linkType { continue } - if keys, ok := s.Keys[link.Type]; ok { - keys[link.Key] = struct{}{} + if keys, ok := s.Keys[linkType]; ok { + keys[key] = struct{}{} } r, ok := r.(GettableResource) if !ok { continue } - linkedResources, err := r.Get(ctx, []string{link.Key}) + linkedResources, err := r.Get(ctx, []string{key}) if err != nil { - return fmt.Errorf("failed to get linked resource %s %s: %w", link.Type, link.Key, err) + return fmt.Errorf("failed to get linked resource %s %s: %w", linkType, key, err) } for _, linkedResource := range linkedResources { if err := s.add(ctx, linkedResource, visited); err != nil { diff --git a/internal/resource/struct.go b/internal/resource/struct.go index deb0605b..e96ad83c 100644 --- a/internal/resource/struct.go +++ b/internal/resource/struct.go @@ -59,6 +59,7 @@ func fieldFromStruct(pf *ParsedField, v reflect.Value) (field *Field, err error) } var fields []Field + var links []Link for i := range t.NumField() { field := t.Field(i) if !field.IsExported() { @@ -66,71 +67,37 @@ func fieldFromStruct(pf *ParsedField, v reflect.Value) (field *Field, err error) } fieldVal := s.Field(i) - parsedField, err := ParseField(field, fieldVal) - if err != nil { - return nil, err - } - if parsedField == nil { + // Handle anonymous fields by embedding their subfields directly + if field.Anonymous { + embedded, err := fieldFromStruct(&ParsedField{Embed: true}, fieldVal) + if err != nil { + return nil, err + } + if embedded != nil { + fields = append(fields, embedded.Subfields...) + links = append(links, embedded.Links...) + } + if link, ok := fieldVal.Interface().(Link); ok { + if t, k := link.Link(); t != "" && k != nil && k.String() != "" { + links = append(links, link) + } + } continue } - var createPatch, editPatch *Patch - if parsedField.Create != nil { - patch := *parsedField.Create - createPatch = &patch - } - if parsedField.Edit != nil { - patch := *parsedField.Edit - editPatch = &patch - } - - result := Field{ - Name: parsedField.Name, - Verbosity: parsedField.Verbosity, - Value: fieldVal.Interface(), - Create: createPatch, - Edit: editPatch, - } - - newField, err := fieldFromStruct(parsedField, fieldVal) - if err != nil { - return nil, err - } - if newField != nil { - result.Value = newField.Value - result.Subfields = newField.Subfields - result.Verbosity = cmp.Or(result.Verbosity, newField.Verbosity) - } - - newField, err = fieldFromSlice(parsedField, fieldVal) + parsedField, err := ParseField(field, fieldVal) if err != nil { return nil, err } - if newField != nil { - result.Value = newField.Value - result.Elem = newField.Elem - result.Subfields = newField.Subfields - result.Verbosity = cmp.Or(result.Verbosity, newField.Verbosity) + if parsedField == nil { + continue } - newField, err = fieldFromMap(parsedField, fieldVal) + result, err := fieldFromValue(parsedField, fieldVal) if err != nil { return nil, err } - if newField != nil { - result.Value = newField.Value - result.Elem = newField.Elem - result.Subfields = newField.Subfields - result.ElemMap = newField.ElemMap - result.Verbosity = cmp.Or(result.Verbosity, newField.Verbosity) - } - - if parsedField.Valueless { - result.Value = nil - } - - result.Verbosity = cmp.Or(result.Verbosity, FieldVerbosityHidden) - fields = append(fields, result) + fields = append(fields, *result) } var value any @@ -142,13 +109,84 @@ func fieldFromStruct(pf *ParsedField, v reflect.Value) (field *Field, err error) for _, f := range fields { verbosity = max(verbosity, f.Verbosity) } + return &Field{ Value: value, Subfields: fields, Verbosity: verbosity, + Links: links, }, nil } +func fieldFromValue(pf *ParsedField, v reflect.Value) (*Field, error) { + var createPatch, editPatch *Patch + if pf.Create != nil { + patch := *pf.Create + createPatch = &patch + } + if pf.Edit != nil { + patch := *pf.Edit + editPatch = &patch + } + + result := Field{ + Name: pf.Name, + Verbosity: pf.Verbosity, + Value: v.Interface(), + Create: createPatch, + Edit: editPatch, + } + + newField, err := fieldFromStruct(pf, v) + if err != nil { + return nil, err + } + if newField != nil { + result.Value = newField.Value + result.Subfields = newField.Subfields + result.Verbosity = cmp.Or(result.Verbosity, newField.Verbosity) + result.Links = append(result.Links, newField.Links...) + } + + newField, err = fieldFromSlice(pf, v) + if err != nil { + return nil, err + } + if newField != nil { + result.Value = newField.Value + result.Elem = newField.Elem + result.Subfields = newField.Subfields + result.Verbosity = cmp.Or(result.Verbosity, newField.Verbosity) + } + + newField, err = fieldFromMap(pf, v) + if err != nil { + return nil, err + } + if newField != nil { + result.Value = newField.Value + result.Elem = newField.Elem + result.Subfields = newField.Subfields + result.ElemMap = newField.ElemMap + result.Verbosity = cmp.Or(result.Verbosity, newField.Verbosity) + } + + // Check if this value implements the Link interface + // This is the ONE place where we check for links on field values + if link, ok := v.Interface().(Link); ok { + if t, k := link.Link(); t != "" && k != nil && k.String() != "" { + result.Links = append(result.Links, link) + } + } + + if pf.Valueless { + result.Value = nil + } + + result.Verbosity = cmp.Or(result.Verbosity, FieldVerbosityHidden) + return &result, nil +} + func fieldFromSlice(pf *ParsedField, v reflect.Value) (field *Field, err error) { if v.Kind() == reflect.Pointer { if v.IsNil() { diff --git a/internal/resource/struct_test.go b/internal/resource/struct_test.go index ef2a90fd..9e2b1250 100644 --- a/internal/resource/struct_test.go +++ b/internal/resource/struct_test.go @@ -521,3 +521,147 @@ func TestFieldsFromStruct_UnexportedFieldsIgnored(t *testing.T) { }) } } + +func TestFieldsFromStruct_AnonymousStructs(t *testing.T) { + type Base struct { + Metro string `field:",short"` + Name string `field:",short"` + UUID string `field:",long"` + } + type Resource struct { + Base + State string `field:",short"` + } + + s := Resource{ + Base: Base{ + Metro: "staging", + Name: "test-resource", + UUID: "abc-123", + }, + State: "running", + } + fields, err := FieldsFromStruct(s) + require.NoError(t, err) + // Base fields should be embedded directly, so we get 4 top-level fields + require.Len(t, fields, 4) + + tests := []struct { + path string + value any + }{ + {"metro", "staging"}, + {"name", "test-resource"}, + {"uuid", "abc-123"}, + {"state", "running"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := GetFieldByPathString(fields, tt.path) + require.Len(t, result, 1, "field %q should exist at top level", tt.path) + assert.Equal(t, tt.value, result[0].Value) + }) + } + + // Ensure we don't have a "base" nested field + baseField := GetFieldByPathString(fields, "base") + assert.Empty(t, baseField, "anonymous struct should not create 'base' field") +} + +// mockLink is a test helper that implements the Link interface +type mockLink struct { + linkType string + linkKey string +} + +func (m mockLink) Link() (string, Key) { + if m.linkType == "" || m.linkKey == "" { + return "", nil + } + return m.linkType, simpleKey(m.linkKey) +} + +func TestFieldsFromStruct_LinkDetection(t *testing.T) { + type Resource struct { + Name string `field:",short"` + Target mockLink `field:",short"` + EmptyRef mockLink `field:",long"` + } + + s := Resource{ + Name: "my-resource", + Target: mockLink{linkType: "instance", linkKey: "my-instance"}, + EmptyRef: mockLink{}, // Empty link should not create a link + } + fields, err := FieldsFromStruct(s) + require.NoError(t, err) + require.Len(t, fields, 3) + + t.Run("populated link creates Link entry", func(t *testing.T) { + result := GetFieldByPathString(fields, "target") + require.Len(t, result, 1) + require.Len(t, result[0].Links, 1, "populated link should create a Link entry") + + linkType, linkKey := result[0].Links[0].Link() + assert.Equal(t, "instance", linkType) + assert.Equal(t, "my-instance", linkKey.String()) + }) + + t.Run("empty link does not create Link entry", func(t *testing.T) { + result := GetFieldByPathString(fields, "empty-ref") + require.Len(t, result, 1) + assert.Empty(t, result[0].Links, "empty link should not create a Link entry") + }) + + t.Run("non-link field has no Links", func(t *testing.T) { + result := GetFieldByPathString(fields, "name") + require.Len(t, result, 1) + assert.Empty(t, result[0].Links, "regular field should not have Links") + }) +} + +// simpleKey is a helper for tests that implements the Key interface +type simpleKey string + +func (k simpleKey) String() string { + return string(k) +} + +func (k simpleKey) Canonical() string { + return string(k) +} + +// TestEmbeddableLink is like mockLink but with exported fields for embedding. +type TestEmbeddableLink struct { + mockLink + Name string `field:",short"` +} + +func TestFieldsFromStruct_EmbeddedLinkDetection(t *testing.T) { + // When a struct embeds a Link type, the link should bubble up to the + // containing struct's Field.Links. This is important for fields with + // the "embed" tag where the struct is processed for its subfields. + type Container struct { + Item *TestEmbeddableLink `field:",short,embed"` + } + + s := Container{ + Item: &TestEmbeddableLink{ + mockLink: mockLink{linkType: "service", linkKey: "svc-123"}, + Name: "my-service", + }, + } + fields, err := FieldsFromStruct(s) + require.NoError(t, err) + require.Len(t, fields, 1) + + // The item field should have the link bubbled up from the embedded Link type + item := GetFieldByPathString(fields, "item") + require.Len(t, item, 1) + require.Len(t, item[0].Links, 1, "embedded link should bubble up to containing field") + + linkType, linkKey := item[0].Links[0].Link() + assert.Equal(t, "service", linkType) + assert.Equal(t, "svc-123", linkKey.String()) +} diff --git a/internal/resource/tui/panel_detail.go b/internal/resource/tui/panel_detail.go index e1fd6e03..e7394b0c 100644 --- a/internal/resource/tui/panel_detail.go +++ b/internal/resource/tui/panel_detail.go @@ -28,7 +28,7 @@ type detailPanel struct { key string table table.Model - rowLinks []*resource.Link + rowLinks []resource.Link err error loading bool @@ -175,7 +175,7 @@ func (p *detailPanel) renderResource(res resource.Resource) { fields = resource.DedupeFields(fields) rows := make([]table.Row, 0) - links := make([]*resource.Link, 0) + links := make([]resource.Link, 0) if err := appendKVRows(&rows, &links, nil, fields, 0); err != nil { p.err = err p.table.SetRows(nil) @@ -200,22 +200,30 @@ func (p *detailPanel) openSelected() tea.Cmd { if link == nil { return nil } + linkType, linkKey := link.Link() + if linkType == "" || linkKey == nil { + return nil + } + key := linkKey.String() + if key == "" { + return nil + } if p.registry == nil { - p.err = fmt.Errorf("unknown resource type: %s", link.Type) + p.err = fmt.Errorf("unknown resource type: %s", linkType) return nil } - desc, ok := p.registry.Resolve(link.Type) + desc, ok := p.registry.Resolve(linkType) if !ok { - p.err = fmt.Errorf("unknown resource type: %s", link.Type) + p.err = fmt.Errorf("unknown resource type: %s", linkType) return nil } - panel := NewDetailPanel(p.ctx, p.registry, desc, link.Key) + panel := NewDetailPanel(p.ctx, p.registry, desc, key) return func() tea.Msg { return uitui.OpenPanelMsg{Panel: panel, Collapse: false} } } -func (p *detailPanel) currentLink() *resource.Link { +func (p *detailPanel) currentLink() resource.Link { idx := p.table.Cursor() if idx < 0 || idx >= len(p.rowLinks) { return nil @@ -241,7 +249,7 @@ func (p *detailPanel) layout() { p.configureTable(p.width, p.height, p.focused) } -func appendKVRows(rows *[]table.Row, links *[]*resource.Link, parent *resource.Field, fields []resource.Field, indent int) error { +func appendKVRows(rows *[]table.Row, links *[]resource.Link, parent *resource.Field, fields []resource.Field, indent int) error { linkColor := compat.AdaptiveColor{Light: colors.Slate600, Dark: colors.Slate400} linkSeq := ansi.NewStyle(ansi.AttrItalic, ansi.AttrUnderline).ForegroundColor(compat.Profile.Convert(linkColor)).String() linkReset := ansi.NewStyle(ansi.AttrNoItalic, ansi.AttrNoUnderline).ForegroundColor(nil).String() @@ -338,12 +346,11 @@ func appendKVRows(rows *[]table.Row, links *[]*resource.Link, parent *resource.F return nil } -func firstLink(field resource.Field) *resource.Link { +func firstLink(field resource.Field) resource.Link { if len(field.Links) == 0 { return nil } - link := field.Links[0] - return &link + return field.Links[0] } func (p *detailPanel) Subpanels() []tea.Model { diff --git a/internal/resource/value/format.go b/internal/resource/value/format.go index fed0d291..bed00889 100644 --- a/internal/resource/value/format.go +++ b/internal/resource/value/format.go @@ -28,6 +28,19 @@ func Format(value any) (string, error) { } value = unwrapped.Unwrap() } + + if value == nil { + return "", nil + } + + // Check for nil pointers before checking for interfaces + // because a nil pointer to a type that implements an interface + // will pass the interface check but panic when calling methods + v := reflect.ValueOf(value) + if v.Kind() == reflect.Pointer && v.IsNil() { + return "", nil + } + if value, ok := value.(fmt.Stringer); ok { return value.String(), nil } @@ -36,16 +49,8 @@ func Format(value any) (string, error) { return string(dt), err } - if value == nil { - return "", nil - } - - v := reflect.ValueOf(value) switch v.Kind() { case reflect.Pointer: - if v.IsNil() { - return "", nil - } return Format(v.Elem().Interface()) case reflect.String: return v.String(), nil diff --git a/internal/types/image.go b/internal/types/image.go index a8861b50..19cbe1a9 100644 --- a/internal/types/image.go +++ b/internal/types/image.go @@ -9,7 +9,10 @@ import ( "strings" "github.com/distribution/reference" + "unikraft.com/cli/internal/images" + "unikraft.com/cli/internal/multimetro" + "unikraft.com/cli/internal/resource" ) // ImageRef is a generic wrapper around a Docker image reference. @@ -39,3 +42,13 @@ func (ir *ImageRef[T]) UnmarshalText(text []byte) error { ir.Reference = ref.(T) return nil } + +func (ir ImageRef[T]) Link() (string, resource.Key) { + var zero T + if ir.Reference == zero { + return "", nil + } + return "image", multimetro.Key{ + Name: ir.Reference.String(), + } +}