diff --git a/cmd/api/api/builds.go b/cmd/api/api/builds.go index 5478edeb..b74ce279 100644 --- a/cmd/api/api/builds.go +++ b/cmd/api/api/builds.go @@ -13,6 +13,7 @@ import ( "github.com/kernel/hypeman/lib/images" "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/oapi" + "github.com/kernel/hypeman/lib/tags" ) // ListBuilds returns all builds @@ -28,9 +29,12 @@ func (s *ApiService) ListBuilds(ctx context.Context, request oapi.ListBuildsRequ }, nil } - oapiBuilds := make([]oapi.Build, len(domainBuilds)) - for i, b := range domainBuilds { - oapiBuilds[i] = buildToOAPI(b) + oapiBuilds := make([]oapi.Build, 0, len(domainBuilds)) + for _, b := range domainBuilds { + if b == nil || !matchesMetadataFilter(b.Metadata, request.Params.Metadata) { + continue + } + oapiBuilds = append(oapiBuilds, buildToOAPI(b)) } return oapi.ListBuilds200JSONResponse(oapiBuilds), nil @@ -46,6 +50,7 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe var timeoutSeconds, memoryMB, cpus int var isAdminBuild bool var secrets []builds.SecretRef + var metadata map[string]string for { part, err := request.Body.NextPart() @@ -169,6 +174,22 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe }, nil } imageName = string(data) + case "metadata": + data, err := io.ReadAll(part) + if err != nil { + return oapi.CreateBuild400JSONResponse{ + Code: "invalid_request", + Message: "failed to read metadata field", + }, nil + } + parsed, err := parseMetadataJSON(string(data)) + if err != nil { + return oapi.CreateBuild400JSONResponse{ + Code: "invalid_request", + Message: "metadata must be a JSON object with string key-value pairs", + }, nil + } + metadata = parsed } part.Close() } @@ -203,6 +224,7 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe IsAdminBuild: isAdminBuild, GlobalCacheKey: globalCacheKey, ImageName: imageName, + Metadata: metadata, } // Apply build policy if any field was provided @@ -223,6 +245,11 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe build, err := s.BuildManager.CreateBuild(ctx, domainReq, sourceData) if err != nil { switch { + case errors.Is(err, tags.ErrInvalidMetadata): + return oapi.CreateBuild400JSONResponse{ + Code: "invalid_request", + Message: err.Error(), + }, nil case errors.Is(err, builds.ErrDockerfileRequired): return oapi.CreateBuild400JSONResponse{ Code: "dockerfile_required", @@ -359,6 +386,7 @@ func buildToOAPI(b *builds.Build) oapi.Build { oapiBuild := oapi.Build{ Id: b.ID, Status: oapi.BuildStatus(b.Status), + Metadata: toOAPIMetadata(b.Metadata), QueuePosition: b.QueuePosition, ImageDigest: b.ImageDigest, ImageRef: b.ImageRef, diff --git a/cmd/api/api/devices.go b/cmd/api/api/devices.go index c40c281f..1d744c9f 100644 --- a/cmd/api/api/devices.go +++ b/cmd/api/api/devices.go @@ -6,6 +6,7 @@ import ( "github.com/kernel/hypeman/lib/devices" "github.com/kernel/hypeman/lib/oapi" + "github.com/kernel/hypeman/lib/tags" ) // ListDevices returns all registered devices @@ -18,9 +19,12 @@ func (s *ApiService) ListDevices(ctx context.Context, request oapi.ListDevicesRe }, nil } - result := make([]oapi.Device, len(deviceList)) - for i, d := range deviceList { - result[i] = deviceToOAPI(d) + result := make([]oapi.Device, 0, len(deviceList)) + for _, d := range deviceList { + if !matchesMetadataFilter(d.Metadata, request.Params.Metadata) { + continue + } + result = append(result, deviceToOAPI(d)) } return oapi.ListDevices200JSONResponse(result), nil @@ -53,11 +57,17 @@ func (s *ApiService) CreateDevice(ctx context.Context, request oapi.CreateDevice req := devices.CreateDeviceRequest{ Name: name, PCIAddress: request.Body.PciAddress, + Metadata: toMapMetadata(request.Body.Metadata), } device, err := s.DeviceManager.CreateDevice(ctx, req) if err != nil { switch { + case errors.Is(err, tags.ErrInvalidMetadata): + return oapi.CreateDevice400JSONResponse{ + Code: "invalid_request", + Message: err.Error(), + }, nil case errors.Is(err, devices.ErrInvalidName): return oapi.CreateDevice400JSONResponse{ Code: "invalid_name", @@ -142,6 +152,7 @@ func deviceToOAPI(d devices.Device) oapi.Device { Id: d.Id, Name: &d.Name, Type: deviceType, + Metadata: toOAPIMetadata(d.Metadata), PciAddress: d.PCIAddress, VendorId: d.VendorID, DeviceId: d.DeviceID, diff --git a/cmd/api/api/images.go b/cmd/api/api/images.go index b5359f31..37844c36 100644 --- a/cmd/api/api/images.go +++ b/cmd/api/api/images.go @@ -8,6 +8,7 @@ import ( "github.com/kernel/hypeman/lib/logger" mw "github.com/kernel/hypeman/lib/middleware" "github.com/kernel/hypeman/lib/oapi" + "github.com/kernel/hypeman/lib/tags" ) func (s *ApiService) ListImages(ctx context.Context, request oapi.ListImagesRequestObject) (oapi.ListImagesResponseObject, error) { @@ -22,11 +23,13 @@ func (s *ApiService) ListImages(ctx context.Context, request oapi.ListImagesRequ }, nil } - oapiImages := make([]oapi.Image, len(domainImages)) - for i, img := range domainImages { - oapiImages[i] = imageToOAPI(img) + oapiImages := make([]oapi.Image, 0, len(domainImages)) + for _, img := range domainImages { + if !matchesMetadataFilter(img.Metadata, request.Params.Metadata) { + continue + } + oapiImages = append(oapiImages, imageToOAPI(img)) } - return oapi.ListImages200JSONResponse(oapiImages), nil } @@ -34,12 +37,18 @@ func (s *ApiService) CreateImage(ctx context.Context, request oapi.CreateImageRe log := logger.FromContext(ctx) domainReq := images.CreateImageRequest{ - Name: request.Body.Name, + Name: request.Body.Name, + Metadata: toMapMetadata(request.Body.Metadata), } img, err := s.ImageManager.CreateImage(ctx, domainReq) if err != nil { switch { + case errors.Is(err, tags.ErrInvalidMetadata): + return oapi.CreateImage400JSONResponse{ + Code: "invalid_request", + Message: err.Error(), + }, nil case errors.Is(err, images.ErrInvalidName): return oapi.CreateImage400JSONResponse{ Code: "invalid_name", @@ -117,6 +126,9 @@ func imageToOAPI(img images.Image) oapi.Image { if len(img.Env) > 0 { oapiImg.Env = &img.Env } + if len(img.Metadata) > 0 { + oapiImg.Metadata = toOAPIMetadata(img.Metadata) + } if img.WorkingDir != "" { oapiImg.WorkingDir = &img.WorkingDir } diff --git a/cmd/api/api/ingress.go b/cmd/api/api/ingress.go index 78cc5464..f0f67522 100644 --- a/cmd/api/api/ingress.go +++ b/cmd/api/api/ingress.go @@ -23,9 +23,12 @@ func (s *ApiService) ListIngresses(ctx context.Context, request oapi.ListIngress }, nil } - oapiIngresses := make([]oapi.Ingress, len(ingresses)) - for i, ing := range ingresses { - oapiIngresses[i] = ingressToOAPI(ing) + oapiIngresses := make([]oapi.Ingress, 0, len(ingresses)) + for _, ing := range ingresses { + if !matchesMetadataFilter(ing.Metadata, request.Params.Metadata) { + continue + } + oapiIngresses = append(oapiIngresses, ingressToOAPI(ing)) } return oapi.ListIngresses200JSONResponse(oapiIngresses), nil @@ -37,8 +40,9 @@ func (s *ApiService) CreateIngress(ctx context.Context, request oapi.CreateIngre // Convert OAPI request to domain request domainReq := ingress.CreateIngressRequest{ - Name: request.Body.Name, - Rules: make([]ingress.IngressRule, len(request.Body.Rules)), + Name: request.Body.Name, + Metadata: toMapMetadata(request.Body.Metadata), + Rules: make([]ingress.IngressRule, len(request.Body.Rules)), } for i, rule := range request.Body.Rules { @@ -180,6 +184,7 @@ func ingressToOAPI(ing ingress.Ingress) oapi.Ingress { return oapi.Ingress{ Id: ing.ID, Name: ing.Name, + Metadata: toOAPIMetadata(ing.Metadata), Rules: rules, CreatedAt: ing.CreatedAt, } diff --git a/cmd/api/api/instances.go b/cmd/api/api/instances.go index 3c94b48e..c968a73e 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -34,7 +34,7 @@ func (s *ApiService) ListInstances(ctx context.Context, request oapi.ListInstanc filter.State = &state } if request.Params.Metadata != nil { - filter.Metadata = *request.Params.Metadata + filter.Metadata = toMapMetadata(request.Params.Metadata) } } @@ -127,7 +127,7 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst metadata := make(map[string]string) if request.Body.Metadata != nil { - metadata = *request.Body.Metadata + metadata = toMapMetadata(request.Body.Metadata) } // Parse network enabled (default: true) @@ -288,6 +288,11 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst Code: "insufficient_resources", Message: err.Error(), }, nil + case errors.Is(err, instances.ErrInvalidRequest): + return oapi.CreateInstance400JSONResponse{ + Code: "invalid_request", + Message: err.Error(), + }, nil default: log.ErrorContext(ctx, "failed to create instance", "error", err, "image", request.Body.Image) return oapi.CreateInstance500JSONResponse{ @@ -849,7 +854,7 @@ func instanceToOAPI(inst instances.Instance) oapi.Instance { } if len(inst.Metadata) > 0 { - oapiInst.Metadata = &inst.Metadata + oapiInst.Metadata = toOAPIMetadata(inst.Metadata) } // Convert volume attachments diff --git a/cmd/api/api/metadata.go b/cmd/api/api/metadata.go new file mode 100644 index 00000000..8539a542 --- /dev/null +++ b/cmd/api/api/metadata.go @@ -0,0 +1,42 @@ +package api + +import ( + "encoding/json" + "fmt" + + "github.com/kernel/hypeman/lib/oapi" + "github.com/kernel/hypeman/lib/tags" +) + +func toMapMetadata(metadata *oapi.MetadataTags) map[string]string { + if metadata == nil { + return nil + } + return tags.Clone(map[string]string(*metadata)) +} + +func toOAPIMetadata(metadata map[string]string) *oapi.MetadataTags { + if len(metadata) == 0 { + return nil + } + cloned := oapi.MetadataTags(tags.Clone(metadata)) + return &cloned +} + +func matchesMetadataFilter(metadata map[string]string, filter *oapi.MetadataTags) bool { + if filter == nil { + return true + } + return tags.Matches(metadata, map[string]string(*filter)) +} + +func parseMetadataJSON(raw string) (map[string]string, error) { + if raw == "" { + return nil, nil + } + var metadata map[string]string + if err := json.Unmarshal([]byte(raw), &metadata); err != nil { + return nil, fmt.Errorf("parse metadata JSON: %w", err) + } + return metadata, nil +} diff --git a/cmd/api/api/snapshots.go b/cmd/api/api/snapshots.go index 19c633e6..b5a70d0c 100644 --- a/cmd/api/api/snapshots.go +++ b/cmd/api/api/snapshots.go @@ -29,8 +29,9 @@ func (s *ApiService) CreateInstanceSnapshot(ctx context.Context, request oapi.Cr } result, err := s.InstanceManager.CreateSnapshot(ctx, inst.Id, instances.CreateSnapshotRequest{ - Kind: instances.SnapshotKind(request.Body.Kind), - Name: name, + Kind: instances.SnapshotKind(request.Body.Kind), + Name: name, + Metadata: toMapMetadata(request.Body.Metadata), }) if err != nil { log := logger.FromContext(ctx) @@ -104,7 +105,10 @@ func (s *ApiService) ListSnapshots(ctx context.Context, request oapi.ListSnapsho if request.Params.Name != nil { filter.Name = request.Params.Name } - if filter.SourceInstanceID == nil && filter.Kind == nil && filter.Name == nil { + if request.Params.Metadata != nil { + filter.Metadata = toMapMetadata(request.Params.Metadata) + } + if filter.SourceInstanceID == nil && filter.Kind == nil && filter.Name == nil && len(filter.Metadata) == 0 { filter = nil } @@ -195,6 +199,7 @@ func snapshotToOAPI(snapshot instances.Snapshot) oapi.Snapshot { out := oapi.Snapshot{ Id: snapshot.Id, Kind: kind, + Metadata: toOAPIMetadata(snapshot.Metadata), SourceInstanceId: snapshot.SourceInstanceID, SourceInstanceName: snapshot.SourceName, SourceHypervisor: sourceHypervisor, diff --git a/cmd/api/api/volumes.go b/cmd/api/api/volumes.go index 66f87d78..7efddd9b 100644 --- a/cmd/api/api/volumes.go +++ b/cmd/api/api/volumes.go @@ -7,6 +7,7 @@ import ( "github.com/kernel/hypeman/lib/logger" mw "github.com/kernel/hypeman/lib/middleware" "github.com/kernel/hypeman/lib/oapi" + "github.com/kernel/hypeman/lib/tags" "github.com/kernel/hypeman/lib/volumes" ) @@ -23,9 +24,12 @@ func (s *ApiService) ListVolumes(ctx context.Context, request oapi.ListVolumesRe }, nil } - oapiVols := make([]oapi.Volume, len(domainVols)) - for i, vol := range domainVols { - oapiVols[i] = volumeToOAPI(vol) + oapiVols := make([]oapi.Volume, 0, len(domainVols)) + for _, vol := range domainVols { + if !matchesMetadataFilter(vol.Metadata, request.Params.Metadata) { + continue + } + oapiVols = append(oapiVols, volumeToOAPI(vol)) } return oapi.ListVolumes200JSONResponse(oapiVols), nil @@ -43,9 +47,10 @@ func (s *ApiService) CreateVolume(ctx context.Context, request oapi.CreateVolume } domainReq := volumes.CreateVolumeRequest{ - Name: request.Body.Name, - SizeGb: request.Body.SizeGb, - Id: request.Body.Id, + Name: request.Body.Name, + SizeGb: request.Body.SizeGb, + Id: request.Body.Id, + Metadata: toMapMetadata(request.Body.Metadata), } vol, err := s.VolumeManager.CreateVolume(ctx, domainReq) @@ -56,6 +61,12 @@ func (s *ApiService) CreateVolume(ctx context.Context, request oapi.CreateVolume Message: "volume with this ID already exists", }, nil } + if errors.Is(err, tags.ErrInvalidMetadata) { + return oapi.CreateVolume400JSONResponse{ + Code: "invalid_request", + Message: err.Error(), + }, nil + } log.ErrorContext(ctx, "failed to create volume", "error", err, "name", request.Body.Name) return oapi.CreateVolume500JSONResponse{ Code: "internal_error", @@ -88,9 +99,10 @@ func (s *ApiService) CreateVolumeFromArchive(ctx context.Context, request oapi.C // Create the volume from archive - stream directly without buffering domainReq := volumes.CreateVolumeFromArchiveRequest{ - Name: request.Params.Name, - SizeGb: request.Params.SizeGb, - Id: request.Params.Id, + Name: request.Params.Name, + SizeGb: request.Params.SizeGb, + Id: request.Params.Id, + Metadata: toMapMetadata(request.Params.Metadata), } vol, err := s.VolumeManager.CreateVolumeFromArchive(ctx, domainReq, request.Body) @@ -107,6 +119,12 @@ func (s *ApiService) CreateVolumeFromArchive(ctx context.Context, request oapi.C Message: "volume with this ID already exists", }, nil } + if errors.Is(err, tags.ErrInvalidMetadata) { + return oapi.CreateVolumeFromArchive400JSONResponse{ + Code: "invalid_request", + Message: err.Error(), + }, nil + } log.ErrorContext(ctx, "failed to create volume from archive", "error", err, "name", request.Params.Name) return oapi.CreateVolumeFromArchive500JSONResponse{ Code: "internal_error", @@ -168,6 +186,7 @@ func volumeToOAPI(vol volumes.Volume) oapi.Volume { Id: vol.Id, Name: vol.Name, SizeGb: vol.SizeGb, + Metadata: toOAPIMetadata(vol.Metadata), CreatedAt: vol.CreatedAt, } diff --git a/lib/builds/manager.go b/lib/builds/manager.go index 9d71734c..41632e82 100644 --- a/lib/builds/manager.go +++ b/lib/builds/manager.go @@ -22,6 +22,7 @@ import ( "github.com/kernel/hypeman/lib/images" "github.com/kernel/hypeman/lib/instances" "github.com/kernel/hypeman/lib/paths" + "github.com/kernel/hypeman/lib/tags" "github.com/kernel/hypeman/lib/volumes" "github.com/nrednav/cuid2" "go.opentelemetry.io/otel/metric" @@ -370,6 +371,10 @@ func (m *manager) waitForBuilderImageReady(ctx context.Context, imageRef string) // CreateBuild starts a new build job func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourceData []byte) (*Build, error) { m.logger.Info("creating build") + if err := tags.Validate(req.Metadata); err != nil { + return nil, err + } + req.Metadata = tags.Clone(req.Metadata) // Apply defaults to build policy policy := req.BuildPolicy @@ -390,6 +395,7 @@ func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourc meta := &buildMetadata{ ID: id, Status: StatusQueued, + Metadata: tags.Clone(req.Metadata), Request: &req, CreatedAt: time.Now(), } diff --git a/lib/builds/storage.go b/lib/builds/storage.go index 24b97588..59ff4fed 100644 --- a/lib/builds/storage.go +++ b/lib/builds/storage.go @@ -8,12 +8,14 @@ import ( "time" "github.com/kernel/hypeman/lib/paths" + "github.com/kernel/hypeman/lib/tags" ) // buildMetadata is the internal representation stored on disk type buildMetadata struct { ID string `json:"id"` Status string `json:"status"` + Metadata tags.Metadata `json:"metadata,omitempty"` Request *CreateBuildRequest `json:"request,omitempty"` ImageDigest *string `json:"image_digest,omitempty"` ImageRef *string `json:"image_ref,omitempty"` @@ -31,6 +33,7 @@ func (m *buildMetadata) toBuild() *Build { return &Build{ ID: m.ID, Status: m.Status, + Metadata: tags.Clone(m.Metadata), ImageDigest: m.ImageDigest, ImageRef: m.ImageRef, Error: m.Error, diff --git a/lib/builds/storage_test.go b/lib/builds/storage_test.go new file mode 100644 index 00000000..45b6db9a --- /dev/null +++ b/lib/builds/storage_test.go @@ -0,0 +1,38 @@ +package builds + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +func TestBuildMetadataReadWrite_MetadataRoundTrip(t *testing.T) { + tempDir := t.TempDir() + p := paths.New(tempDir) + id := "build-meta-1" + + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "builds", id), 0755)) + + meta := &buildMetadata{ + ID: id, + Status: StatusQueued, + Metadata: map[string]string{"team": "backend", "env": "staging"}, + CreatedAt: time.Now().UTC().Truncate(time.Second), + } + + require.NoError(t, writeMetadata(p, meta)) + + loaded, err := readMetadata(p, id) + require.NoError(t, err) + require.Equal(t, map[string]string{"team": "backend", "env": "staging"}, loaded.Metadata) + + build := loaded.toBuild() + require.Equal(t, map[string]string{"team": "backend", "env": "staging"}, build.Metadata) + + loaded.Metadata["team"] = "mutated" + require.Equal(t, "backend", build.Metadata["team"]) +} diff --git a/lib/builds/types.go b/lib/builds/types.go index 7de9d876..e312d9d6 100644 --- a/lib/builds/types.go +++ b/lib/builds/types.go @@ -5,6 +5,8 @@ package builds import ( "fmt" "time" + + "github.com/kernel/hypeman/lib/tags" ) // Build status constants @@ -21,6 +23,7 @@ const ( type Build struct { ID string `json:"id"` Status string `json:"status"` + Metadata tags.Metadata `json:"metadata,omitempty"` QueuePosition *int `json:"queue_position,omitempty"` ImageDigest *string `json:"image_digest,omitempty"` ImageRef *string `json:"image_ref,omitempty"` @@ -69,6 +72,9 @@ type CreateBuildRequest struct { // ImageName optionally sets a custom image name for the build output. // When set, the image is pushed to {registry}/{image_name} instead of {registry}/builds/{id}. ImageName string `json:"image_name,omitempty"` + + // Metadata is optional user-defined key-value metadata for the build resource. + Metadata tags.Metadata `json:"metadata,omitempty"` } // BuildPolicy defines resource limits and network policy for a build diff --git a/lib/devices/manager.go b/lib/devices/manager.go index 74f34bb4..a152daa7 100644 --- a/lib/devices/manager.go +++ b/lib/devices/manager.go @@ -12,6 +12,7 @@ import ( "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/paths" + "github.com/kernel/hypeman/lib/tags" "github.com/nrednav/cuid2" ) @@ -140,6 +141,9 @@ func (m *manager) ListAvailableDevices(ctx context.Context) ([]AvailableDevice, func (m *manager) CreateDevice(ctx context.Context, req CreateDeviceRequest) (*Device, error) { log := logger.FromContext(ctx) + if err := tags.Validate(req.Metadata); err != nil { + return nil, err + } // Validate PCI address format (required) if !ValidatePCIAddress(req.PCIAddress) { @@ -185,6 +189,7 @@ func (m *manager) CreateDevice(ctx context.Context, req CreateDeviceRequest) (*D Id: id, Name: name, Type: DetermineDeviceType(deviceInfo), + Metadata: tags.Clone(req.Metadata), PCIAddress: req.PCIAddress, VendorID: deviceInfo.VendorID, DeviceID: deviceInfo.DeviceID, diff --git a/lib/devices/manager_test.go b/lib/devices/manager_test.go index bc512f77..5a6f856b 100644 --- a/lib/devices/manager_test.go +++ b/lib/devices/manager_test.go @@ -1,8 +1,11 @@ package devices import ( + "os" "testing" + "time" + "github.com/kernel/hypeman/lib/paths" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -161,3 +164,32 @@ func TestErrors(t *testing.T) { assert.Contains(t, ErrInvalidName.Error(), "pattern") }) } + +func TestSaveLoadDevice_MetadataRoundTrip(t *testing.T) { + p := paths.New(t.TempDir()) + mgr := &manager{ + paths: p, + vfioBinder: NewVFIOBinder(), + } + + device := &Device{ + Id: "dev-meta-1", + Name: "meta-device", + Type: DeviceTypeGeneric, + Metadata: map[string]string{"team": "platform", "env": "prod"}, + PCIAddress: "0000:00:00.0", + VendorID: "1234", + DeviceID: "5678", + CreatedAt: time.Now().UTC(), + } + + require.NoError(t, os.MkdirAll(p.DeviceDir(device.Id), 0755)) + require.NoError(t, mgr.saveDevice(device)) + + loaded, err := mgr.loadDevice(device.Id) + require.NoError(t, err) + require.Equal(t, map[string]string{"team": "platform", "env": "prod"}, loaded.Metadata) + + device.Metadata["team"] = "mutated" + require.Equal(t, "platform", loaded.Metadata["team"]) +} diff --git a/lib/devices/types.go b/lib/devices/types.go index d436ca1d..3dd5a95f 100644 --- a/lib/devices/types.go +++ b/lib/devices/types.go @@ -3,6 +3,8 @@ package devices import ( "regexp" "time" + + "github.com/kernel/hypeman/lib/tags" ) // DeviceType represents the type of PCI device @@ -15,22 +17,24 @@ const ( // Device represents a registered PCI device for passthrough type Device struct { - Id string `json:"id"` // cuid2 identifier - Name string `json:"name"` // user-provided globally unique name - Type DeviceType `json:"type"` // gpu or pci - PCIAddress string `json:"pci_address"` // e.g., "0000:a2:00.0" - VendorID string `json:"vendor_id"` // e.g., "10de" - DeviceID string `json:"device_id"` // e.g., "27b8" - IOMMUGroup int `json:"iommu_group"` // IOMMU group number - BoundToVFIO bool `json:"bound_to_vfio"` // whether device is bound to vfio-pci - AttachedTo *string `json:"attached_to"` // instance ID if attached, nil otherwise - CreatedAt time.Time `json:"created_at"` + Id string `json:"id"` // cuid2 identifier + Name string `json:"name"` // user-provided globally unique name + Type DeviceType `json:"type"` // gpu or pci + Metadata tags.Metadata `json:"metadata,omitempty"` // user-defined key-value metadata + PCIAddress string `json:"pci_address"` // e.g., "0000:a2:00.0" + VendorID string `json:"vendor_id"` // e.g., "10de" + DeviceID string `json:"device_id"` // e.g., "27b8" + IOMMUGroup int `json:"iommu_group"` // IOMMU group number + BoundToVFIO bool `json:"bound_to_vfio"` // whether device is bound to vfio-pci + AttachedTo *string `json:"attached_to"` // instance ID if attached, nil otherwise + CreatedAt time.Time `json:"created_at"` } // CreateDeviceRequest is the request to register a new device type CreateDeviceRequest struct { - Name string `json:"name,omitempty"` // optional: globally unique name (auto-generated if not provided) - PCIAddress string `json:"pci_address"` // required: PCI address (e.g., "0000:a2:00.0") + Name string `json:"name,omitempty"` // optional: globally unique name (auto-generated if not provided) + PCIAddress string `json:"pci_address"` // required: PCI address (e.g., "0000:a2:00.0") + Metadata tags.Metadata `json:"metadata,omitempty"` } // AvailableDevice represents a PCI device discovered on the host diff --git a/lib/images/manager.go b/lib/images/manager.go index ac170c0e..e74a60de 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -12,6 +12,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/kernel/hypeman/lib/paths" + "github.com/kernel/hypeman/lib/tags" "go.opentelemetry.io/otel/metric" ) @@ -104,6 +105,10 @@ func (m *manager) ListImages(ctx context.Context) ([]Image, error) { } func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Image, error) { + if err := tags.Validate(req.Metadata); err != nil { + return nil, err + } + // Parse and normalize normalized, err := ParseNormalizedRef(req.Name) if err != nil { @@ -148,7 +153,7 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima } // Don't have this digest yet, queue the build - return m.createAndQueueImage(ref) + return m.createAndQueueImage(ref, req) } // ImportLocalImage imports an image from the local OCI cache without resolving from a remote registry. @@ -195,15 +200,19 @@ func (m *manager) ImportLocalImage(ctx context.Context, repo, reference, digest } // Don't have this digest yet, queue the build - return m.createAndQueueImage(ref) + return m.createAndQueueImage(ref, CreateImageRequest{Name: imageRef}) } -func (m *manager) createAndQueueImage(ref *ResolvedRef) (*Image, error) { +func (m *manager) createAndQueueImage(ref *ResolvedRef, req CreateImageRequest) (*Image, error) { meta := &imageMetadata{ - Name: ref.String(), - Digest: ref.Digest(), - Status: StatusPending, - Request: &CreateImageRequest{Name: ref.String()}, + Name: ref.String(), + Digest: ref.Digest(), + Status: StatusPending, + Request: &CreateImageRequest{ + Name: ref.String(), + Metadata: tags.Clone(req.Metadata), + }, + Metadata: tags.Clone(req.Metadata), CreatedAt: time.Now(), } @@ -213,7 +222,7 @@ func (m *manager) createAndQueueImage(ref *ResolvedRef) (*Image, error) { } // Enqueue the build using digest as the queue key for deduplication - queuePos := m.queue.Enqueue(ref.Digest(), CreateImageRequest{Name: ref.String()}, func() { + queuePos := m.queue.Enqueue(ref.Digest(), CreateImageRequest{Name: ref.String(), Metadata: tags.Clone(req.Metadata)}, func() { m.buildImage(context.Background(), ref) }) diff --git a/lib/images/storage.go b/lib/images/storage.go index 26d73271..364e6c15 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -9,6 +9,7 @@ import ( "time" "github.com/kernel/hypeman/lib/paths" + "github.com/kernel/hypeman/lib/tags" ) type imageMetadata struct { @@ -21,6 +22,7 @@ type imageMetadata struct { Entrypoint []string `json:"entrypoint,omitempty"` Cmd []string `json:"cmd,omitempty"` Env map[string]string `json:"env,omitempty"` + Metadata tags.Metadata `json:"metadata,omitempty"` WorkingDir string `json:"working_dir,omitempty"` CreatedAt time.Time `json:"created_at"` } @@ -48,6 +50,9 @@ func (m *imageMetadata) toImage() *Image { if len(m.Env) > 0 { img.Env = m.Env } + if len(m.Metadata) > 0 { + img.Metadata = tags.Clone(m.Metadata) + } if m.WorkingDir != "" { img.WorkingDir = m.WorkingDir } diff --git a/lib/images/storage_test.go b/lib/images/storage_test.go new file mode 100644 index 00000000..0473b827 --- /dev/null +++ b/lib/images/storage_test.go @@ -0,0 +1,41 @@ +package images + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestImageMetadataToImage_ClonesMetadata(t *testing.T) { + createdAt := time.Now().UTC().Truncate(time.Second) + source := &imageMetadata{ + Name: "docker.io/library/alpine:latest", + Digest: "sha256:abc", + Status: StatusReady, + Metadata: map[string]string{"team": "backend", "env": "staging"}, + SizeBytes: 123, + CreatedAt: createdAt, + } + + img := source.toImage() + require.Equal(t, source.Name, img.Name) + require.Equal(t, source.Digest, img.Digest) + require.Equal(t, map[string]string{"team": "backend", "env": "staging"}, img.Metadata) + require.NotNil(t, img.SizeBytes) + require.Equal(t, int64(123), *img.SizeBytes) + + source.Metadata["team"] = "mutated" + require.Equal(t, "backend", img.Metadata["team"]) +} + +func TestImageMetadataToImage_EmptyMetadataOmitted(t *testing.T) { + img := (&imageMetadata{ + Name: "docker.io/library/alpine:latest", + Digest: "sha256:abc", + Status: StatusPending, + CreatedAt: time.Now().UTC(), + }).toImage() + + require.Nil(t, img.Metadata) +} diff --git a/lib/images/types.go b/lib/images/types.go index 7431e90a..b16ff943 100644 --- a/lib/images/types.go +++ b/lib/images/types.go @@ -1,6 +1,10 @@ package images -import "time" +import ( + "time" + + "github.com/kernel/hypeman/lib/tags" +) // Image represents a container image converted to bootable disk type Image struct { @@ -13,11 +17,13 @@ type Image struct { Entrypoint []string Cmd []string Env map[string]string + Metadata tags.Metadata WorkingDir string CreatedAt time.Time } // CreateImageRequest represents a request to create an image type CreateImageRequest struct { - Name string + Name string + Metadata tags.Metadata } diff --git a/lib/ingress/manager.go b/lib/ingress/manager.go index d0e06b16..fedc453e 100644 --- a/lib/ingress/manager.go +++ b/lib/ingress/manager.go @@ -13,6 +13,7 @@ import ( "github.com/kernel/hypeman/lib/dns" "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/paths" + "github.com/kernel/hypeman/lib/tags" "github.com/nrednav/cuid2" ) @@ -350,6 +351,7 @@ func (m *manager) Create(ctx context.Context, req CreateIngressRequest) (*Ingres ingress := Ingress{ ID: id, Name: req.Name, + Metadata: tags.Clone(req.Metadata), Rules: req.Rules, CreatedAt: time.Now().UTC(), } @@ -375,6 +377,7 @@ func (m *manager) Create(ctx context.Context, req CreateIngressRequest) (*Ingres stored := &storedIngress{ ID: ingress.ID, Name: ingress.Name, + Metadata: tags.Clone(ingress.Metadata), Rules: ingress.Rules, CreatedAt: ingress.CreatedAt.Format(time.RFC3339), } @@ -622,6 +625,7 @@ func storedToIngress(stored *storedIngress) *Ingress { return &Ingress{ ID: stored.ID, Name: stored.Name, + Metadata: tags.Clone(stored.Metadata), Rules: stored.Rules, CreatedAt: createdAt, } diff --git a/lib/ingress/manager_test.go b/lib/ingress/manager_test.go index faf5bdc4..9059d367 100644 --- a/lib/ingress/manager_test.go +++ b/lib/ingress/manager_test.go @@ -187,6 +187,40 @@ func TestCreateIngress_DefaultPort(t *testing.T) { assert.Equal(t, 80, ing.Rules[0].Match.GetPort()) // But GetPort returns 80 } +func TestCreateIngress_MetadataRoundTrip(t *testing.T) { + manager, _, _, cleanup := setupTestManager(t) + defer cleanup() + ctx := context.Background() + + reqMetadata := map[string]string{"team": "api", "env": "staging"} + req := CreateIngressRequest{ + Name: "metadata-ingress", + Metadata: reqMetadata, + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "metadata.example.com"}, + Target: IngressTarget{Instance: "my-api", Port: 8080}, + }, + }, + } + + ing, err := manager.Create(ctx, req) + require.NoError(t, err) + require.Equal(t, map[string]string{"team": "api", "env": "staging"}, ing.Metadata) + + reqMetadata["team"] = "mutated" + require.Equal(t, "api", ing.Metadata["team"]) + + got, err := manager.Get(ctx, ing.ID) + require.NoError(t, err) + require.Equal(t, map[string]string{"team": "api", "env": "staging"}, got.Metadata) + + listed, err := manager.List(ctx) + require.NoError(t, err) + require.Len(t, listed, 1) + require.Equal(t, map[string]string{"team": "api", "env": "staging"}, listed[0].Metadata) +} + func TestCreateIngress_InvalidName(t *testing.T) { manager, _, _, cleanup := setupTestManager(t) defer cleanup() diff --git a/lib/ingress/storage.go b/lib/ingress/storage.go index 53a7188a..ca6a78bc 100644 --- a/lib/ingress/storage.go +++ b/lib/ingress/storage.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/kernel/hypeman/lib/paths" + "github.com/kernel/hypeman/lib/tags" ) // Filesystem structure: @@ -16,6 +17,7 @@ import ( type storedIngress struct { ID string `json:"id"` Name string `json:"name"` + Metadata tags.Metadata `json:"metadata,omitempty"` Rules []IngressRule `json:"rules"` CreatedAt string `json:"created_at"` // RFC3339 format } diff --git a/lib/ingress/types.go b/lib/ingress/types.go index f13f0985..f1f6cee6 100644 --- a/lib/ingress/types.go +++ b/lib/ingress/types.go @@ -6,6 +6,8 @@ import ( "strconv" "strings" "time" + + "github.com/kernel/hypeman/lib/tags" ) // Ingress represents an ingress resource that defines how external traffic @@ -17,6 +19,9 @@ type Ingress struct { // Name is a human-readable name for the ingress. Name string `json:"name"` + // Metadata is optional user-defined key-value metadata. + Metadata tags.Metadata `json:"metadata,omitempty"` + // Rules define the routing rules for this ingress. Rules []IngressRule `json:"rules"` @@ -172,6 +177,9 @@ type CreateIngressRequest struct { // Name is a human-readable name for the ingress. Name string `json:"name"` + // Metadata is optional user-defined key-value metadata. + Metadata tags.Metadata `json:"metadata,omitempty"` + // Rules define the routing rules for this ingress. Rules []IngressRule `json:"rules"` } @@ -185,6 +193,9 @@ func (r *CreateIngressRequest) Validate() error { if len(r.Rules) == 0 { return &ValidationError{Field: "rules", Message: "at least one rule is required"} } + if err := tags.Validate(r.Metadata); err != nil { + return &ValidationError{Field: "metadata", Message: err.Error()} + } for i, rule := range r.Rules { if rule.Match.Hostname == "" { diff --git a/lib/instances/create.go b/lib/instances/create.go index 4566544c..1f762ff3 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -13,6 +13,7 @@ import ( "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/network" "github.com/kernel/hypeman/lib/system" + "github.com/kernel/hypeman/lib/tags" "github.com/kernel/hypeman/lib/volumes" "github.com/nrednav/cuid2" "go.opentelemetry.io/otel/attribute" @@ -293,7 +294,7 @@ func (m *manager) createInstance( NetworkBandwidthUpload: req.NetworkBandwidthUpload, // Will be set by caller if using resource manager DiskIOBps: req.DiskIOBps, // Will be set by caller if using resource manager Env: req.Env, - Metadata: req.Metadata, + Metadata: tags.Clone(req.Metadata), NetworkEnabled: req.NetworkEnabled, CreatedAt: time.Now(), StartedAt: nil, @@ -466,6 +467,9 @@ func validateCreateRequest(req CreateInstanceRequest) error { if req.Vcpus < 0 { return fmt.Errorf("vcpus cannot be negative") } + if err := tags.Validate(req.Metadata); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidRequest, err) + } // Validate volume attachments if err := validateVolumeAttachments(req.Volumes); err != nil { diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 0ea65d4e..4df46078 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -12,6 +12,7 @@ import ( "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/network" snapshotstore "github.com/kernel/hypeman/lib/snapshot" + "github.com/kernel/hypeman/lib/tags" "github.com/nrednav/cuid2" "gvisor.dev/gvisor/pkg/cleanup" ) @@ -124,6 +125,7 @@ func (m *manager) createSnapshot(ctx context.Context, id string, req CreateSnaps Id: snapshotID, Name: req.Name, Kind: req.Kind, + Metadata: tags.Clone(req.Metadata), SourceInstanceID: stored.Id, SourceName: stored.Name, SourceHypervisor: stored.HypervisorType, @@ -155,6 +157,7 @@ func (m *manager) createSnapshot(ctx context.Context, id string, req CreateSnaps Id: snapshotID, Name: req.Name, Kind: req.Kind, + Metadata: tags.Clone(req.Metadata), SourceInstanceID: stored.Id, SourceName: stored.Name, SourceHypervisor: stored.HypervisorType, @@ -458,6 +461,9 @@ func validateCreateSnapshotRequest(req CreateSnapshotRequest) error { if req.Kind != SnapshotKindStandby && req.Kind != SnapshotKindStopped { return fmt.Errorf("%w: kind must be one of %s, %s", ErrInvalidRequest, SnapshotKindStandby, SnapshotKindStopped) } + if err := tags.Validate(req.Metadata); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidRequest, err) + } if req.Name != "" { if err := validateInstanceName(req.Name); err != nil { return fmt.Errorf("%w: %v", ErrInvalidRequest, err) diff --git a/lib/instances/types.go b/lib/instances/types.go index 6c373302..bb548d6a 100644 --- a/lib/instances/types.go +++ b/lib/instances/types.go @@ -5,6 +5,7 @@ import ( "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/snapshot" + "github.com/kernel/hypeman/lib/tags" ) // State represents the instance state @@ -47,10 +48,10 @@ type StoredMetadata struct { // Configuration Env map[string]string - Metadata map[string]string // User-defined key-value metadata - NetworkEnabled bool // Whether instance has networking enabled (uses default network) - IP string // Assigned IP address (empty if NetworkEnabled=false) - MAC string // Assigned MAC address (empty if NetworkEnabled=false) + Metadata tags.Metadata // User-defined key-value metadata + NetworkEnabled bool // Whether instance has networking enabled (uses default network) + IP string // Assigned IP address (empty if NetworkEnabled=false) + MAC string // Assigned MAC address (empty if NetworkEnabled=false) // Attached volumes Volumes []VolumeAttachment // Volumes attached to this instance @@ -118,8 +119,8 @@ func (i *Instance) GetHypervisorType() string { // ListInstancesFilter contains optional filters for listing instances. // All fields are ANDed together: an instance must match every specified filter. type ListInstancesFilter struct { - State *State // Filter by instance state - Metadata map[string]string // Filter by metadata key-value pairs (all must match) + State *State // Filter by instance state + Metadata tags.Metadata // Filter by metadata key-value pairs (all must match) } // Matches returns true if the given instance satisfies all filter criteria. @@ -158,7 +159,7 @@ type CreateInstanceRequest struct { NetworkBandwidthUpload int64 // Upload rate limit bytes/sec (0 = auto, proportional to CPU) DiskIOBps int64 // Disk I/O rate limit bytes/sec (0 = auto, proportional to CPU) Env map[string]string // Optional environment variables - Metadata map[string]string // Optional user-defined key-value metadata + Metadata tags.Metadata // Optional user-defined key-value metadata NetworkEnabled bool // Whether to enable networking (uses default network) Devices []string // Device IDs or names to attach (GPU passthrough) Volumes []VolumeAttachment // Volumes to attach at creation time @@ -201,8 +202,9 @@ type ListSnapshotsFilter = snapshot.ListSnapshotsFilter // CreateSnapshotRequest is the domain request for creating a snapshot. type CreateSnapshotRequest struct { - Kind SnapshotKind // Required: Standby or Stopped - Name string // Optional: unique per source instance + Kind SnapshotKind // Required: Standby or Stopped + Name string // Optional: unique per source instance + Metadata tags.Metadata // Optional user-defined key-value metadata } // RestoreSnapshotRequest is the domain request for restoring a snapshot in-place. diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index 1121099b..674b6e6d 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -207,7 +207,10 @@ type Build struct { ImageDigest *string `json:"image_digest"` // ImageRef Full image reference (only when status is ready) - ImageRef *string `json:"image_ref"` + ImageRef *string `json:"image_ref"` + + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` Provenance *BuildProvenance `json:"provenance,omitempty"` // QueuePosition Position in build queue (only when status is queued) @@ -261,6 +264,9 @@ type BuildStatus string // CreateDeviceRequest defines model for CreateDeviceRequest. type CreateDeviceRequest struct { + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` + // Name Optional globally unique device name. If not provided, a name is auto-generated from the PCI address (e.g., "pci-0000-a2-00-0") Name *string `json:"name,omitempty"` @@ -270,12 +276,18 @@ type CreateDeviceRequest struct { // CreateImageRequest defines model for CreateImageRequest. type CreateImageRequest struct { + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` + // Name OCI image reference (e.g., docker.io/library/nginx:latest) Name string `json:"name"` } // CreateIngressRequest defines model for CreateIngressRequest. type CreateIngressRequest struct { + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` + // Name Human-readable name (lowercase letters, digits, and dashes only; cannot start or end with a dash) Name string `json:"name"` @@ -312,8 +324,8 @@ type CreateInstanceRequest struct { // Image OCI image reference Image string `json:"image"` - // Metadata User-defined key-value metadata for the instance - Metadata *map[string]string `json:"metadata,omitempty"` + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` // Name Human-readable name (lowercase letters, digits, and dashes only; cannot start or end with a dash) Name string `json:"name"` @@ -362,6 +374,9 @@ type CreateSnapshotRequest struct { // Kind Snapshot capture kind Kind SnapshotKind `json:"kind"` + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` + // Name Optional snapshot name (lowercase letters, digits, and dashes only; cannot start or end with a dash) Name *string `json:"name,omitempty"` } @@ -371,6 +386,9 @@ type CreateVolumeRequest struct { // Id Optional custom identifier (auto-generated if not provided) Id *string `json:"id,omitempty"` + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` + // Name Volume name Name string `json:"name"` @@ -400,6 +418,9 @@ type Device struct { // IommuGroup IOMMU group number IommuGroup int `json:"iommu_group"` + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` + // Name Device name (user-provided or auto-generated from PCI address) Name *string `json:"name,omitempty"` @@ -553,6 +574,9 @@ type Image struct { // Error Error message if status is failed Error *string `json:"error"` + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` + // Name Normalized OCI image reference (tag or digest) Name string `json:"name"` @@ -580,6 +604,9 @@ type Ingress struct { // Id Auto-generated unique identifier Id string `json:"id"` + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` + // Name Human-readable name Name string `json:"name"` @@ -662,8 +689,8 @@ type Instance struct { // Image OCI image reference Image string `json:"image"` - // Metadata User-defined key-value metadata - Metadata *map[string]string `json:"metadata,omitempty"` + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` // Name Human-readable name Name string `json:"name"` @@ -776,6 +803,9 @@ type InstanceStats struct { NetworkTxBytes int64 `json:"network_tx_bytes"` } +// MetadataTags User-defined key-value metadata tags. +type MetadataTags map[string]string + // PassthroughDevice Physical GPU available for passthrough type PassthroughDevice struct { // Available Whether this GPU is available (not attached to an instance) @@ -902,6 +932,9 @@ type Snapshot struct { // Kind Snapshot capture kind Kind SnapshotKind `json:"kind"` + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` + // Name Optional human-readable snapshot name (unique per source instance) Name *string `json:"name"` @@ -938,6 +971,9 @@ type Volume struct { // Id Unique identifier Id string `json:"id"` + // Metadata User-defined key-value metadata tags. + Metadata *MetadataTags `json:"metadata,omitempty"` + // Name Volume name Name string `json:"name"` @@ -975,6 +1011,12 @@ type VolumeMount struct { VolumeId string `json:"volume_id"` } +// ListBuildsParams defines parameters for ListBuilds. +type ListBuildsParams struct { + // Metadata Filter builds by metadata key-value pairs. + Metadata *MetadataTags `json:"metadata,omitempty"` +} + // CreateBuildMultipartBody defines parameters for CreateBuild. type CreateBuildMultipartBody struct { // BaseImageDigest Optional pinned base image digest @@ -1005,6 +1047,10 @@ type CreateBuildMultipartBody struct { // MemoryMb Memory limit for builder VM in MB (default 2048) MemoryMb *int `json:"memory_mb,omitempty"` + // Metadata JSON object of metadata tags. + // Example: {"team":"backend","env":"staging"} + Metadata *string `json:"metadata,omitempty"` + // Secrets JSON array of secret references to inject during build. // Each object has "id" (required) for use with --mount=type=secret,id=... // Example: [{"id": "npm_token"}, {"id": "github_token"}] @@ -1023,6 +1069,24 @@ type GetBuildEventsParams struct { Follow *bool `form:"follow,omitempty" json:"follow,omitempty"` } +// ListDevicesParams defines parameters for ListDevices. +type ListDevicesParams struct { + // Metadata Filter devices by metadata key-value pairs. + Metadata *MetadataTags `json:"metadata,omitempty"` +} + +// ListImagesParams defines parameters for ListImages. +type ListImagesParams struct { + // Metadata Filter images by metadata key-value pairs. + Metadata *MetadataTags `json:"metadata,omitempty"` +} + +// ListIngressesParams defines parameters for ListIngresses. +type ListIngressesParams struct { + // Metadata Filter ingresses by metadata key-value pairs. + Metadata *MetadataTags `json:"metadata,omitempty"` +} + // ListInstancesParams defines parameters for ListInstances. type ListInstancesParams struct { // State Filter instances by state (e.g., Running, Stopped) @@ -1031,7 +1095,7 @@ type ListInstancesParams struct { // Metadata Filter instances by metadata key-value pairs. Uses deepObject style: // ?metadata[team]=backend&metadata[env]=staging // Multiple entries are ANDed together. All specified key-value pairs must match. - Metadata *map[string]string `json:"metadata,omitempty"` + Metadata *MetadataTags `json:"metadata,omitempty"` } // GetInstanceLogsParams defines parameters for GetInstanceLogs. @@ -1080,6 +1144,15 @@ type ListSnapshotsParams struct { // Name Filter snapshots by snapshot name Name *string `form:"name,omitempty" json:"name,omitempty"` + + // Metadata Filter snapshots by metadata key-value pairs. + Metadata *MetadataTags `json:"metadata,omitempty"` +} + +// ListVolumesParams defines parameters for ListVolumes. +type ListVolumesParams struct { + // Metadata Filter volumes by metadata key-value pairs. + Metadata *MetadataTags `json:"metadata,omitempty"` } // CreateVolumeFromArchiveParams defines parameters for CreateVolumeFromArchive. @@ -1092,6 +1165,9 @@ type CreateVolumeFromArchiveParams struct { // Id Optional custom volume ID (auto-generated if not provided) Id *string `form:"id,omitempty" json:"id,omitempty"` + + // Metadata Metadata tags for the created volume. + Metadata *MetadataTags `json:"metadata,omitempty"` } // CreateBuildMultipartRequestBody defines body for CreateBuild for multipart/form-data ContentType. @@ -1204,7 +1280,7 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { // ListBuilds request - ListBuilds(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + ListBuilds(ctx context.Context, params *ListBuildsParams, reqEditors ...RequestEditorFn) (*http.Response, error) // CreateBuildWithBody request with any body CreateBuildWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1219,7 +1295,7 @@ type ClientInterface interface { GetBuildEvents(ctx context.Context, id string, params *GetBuildEventsParams, reqEditors ...RequestEditorFn) (*http.Response, error) // ListDevices request - ListDevices(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + ListDevices(ctx context.Context, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*http.Response, error) // CreateDeviceWithBody request with any body CreateDeviceWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1239,7 +1315,7 @@ type ClientInterface interface { GetHealth(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) // ListImages request - ListImages(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + ListImages(ctx context.Context, params *ListImagesParams, reqEditors ...RequestEditorFn) (*http.Response, error) // CreateImageWithBody request with any body CreateImageWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1253,7 +1329,7 @@ type ClientInterface interface { GetImage(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*http.Response, error) // ListIngresses request - ListIngresses(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + ListIngresses(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*http.Response, error) // CreateIngressWithBody request with any body CreateIngressWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1344,7 +1420,7 @@ type ClientInterface interface { ForkSnapshot(ctx context.Context, snapshotId string, body ForkSnapshotJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) // ListVolumes request - ListVolumes(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + ListVolumes(ctx context.Context, params *ListVolumesParams, reqEditors ...RequestEditorFn) (*http.Response, error) // CreateVolumeWithBody request with any body CreateVolumeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1361,8 +1437,8 @@ type ClientInterface interface { GetVolume(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) } -func (c *Client) ListBuilds(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewListBuildsRequest(c.Server) +func (c *Client) ListBuilds(ctx context.Context, params *ListBuildsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListBuildsRequest(c.Server, params) if err != nil { return nil, err } @@ -1421,8 +1497,8 @@ func (c *Client) GetBuildEvents(ctx context.Context, id string, params *GetBuild return c.Client.Do(req) } -func (c *Client) ListDevices(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewListDevicesRequest(c.Server) +func (c *Client) ListDevices(ctx context.Context, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListDevicesRequest(c.Server, params) if err != nil { return nil, err } @@ -1505,8 +1581,8 @@ func (c *Client) GetHealth(ctx context.Context, reqEditors ...RequestEditorFn) ( return c.Client.Do(req) } -func (c *Client) ListImages(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewListImagesRequest(c.Server) +func (c *Client) ListImages(ctx context.Context, params *ListImagesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListImagesRequest(c.Server, params) if err != nil { return nil, err } @@ -1565,8 +1641,8 @@ func (c *Client) GetImage(ctx context.Context, name string, reqEditors ...Reques return c.Client.Do(req) } -func (c *Client) ListIngresses(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewListIngressesRequest(c.Server) +func (c *Client) ListIngresses(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListIngressesRequest(c.Server, params) if err != nil { return nil, err } @@ -1961,8 +2037,8 @@ func (c *Client) ForkSnapshot(ctx context.Context, snapshotId string, body ForkS return c.Client.Do(req) } -func (c *Client) ListVolumes(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewListVolumesRequest(c.Server) +func (c *Client) ListVolumes(ctx context.Context, params *ListVolumesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListVolumesRequest(c.Server, params) if err != nil { return nil, err } @@ -2034,7 +2110,7 @@ func (c *Client) GetVolume(ctx context.Context, id string, reqEditors ...Request } // NewListBuildsRequest generates requests for ListBuilds -func NewListBuildsRequest(server string) (*http.Request, error) { +func NewListBuildsRequest(server string, params *ListBuildsParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -2052,6 +2128,28 @@ func NewListBuildsRequest(server string) (*http.Request, error) { return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.Metadata != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("deepObject", true, "metadata", runtime.ParamLocationQuery, *params.Metadata); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err @@ -2214,7 +2312,7 @@ func NewGetBuildEventsRequest(server string, id string, params *GetBuildEventsPa } // NewListDevicesRequest generates requests for ListDevices -func NewListDevicesRequest(server string) (*http.Request, error) { +func NewListDevicesRequest(server string, params *ListDevicesParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -2232,6 +2330,28 @@ func NewListDevicesRequest(server string) (*http.Request, error) { return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.Metadata != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("deepObject", true, "metadata", runtime.ParamLocationQuery, *params.Metadata); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err @@ -2403,7 +2523,7 @@ func NewGetHealthRequest(server string) (*http.Request, error) { } // NewListImagesRequest generates requests for ListImages -func NewListImagesRequest(server string) (*http.Request, error) { +func NewListImagesRequest(server string, params *ListImagesParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -2421,6 +2541,28 @@ func NewListImagesRequest(server string) (*http.Request, error) { return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.Metadata != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("deepObject", true, "metadata", runtime.ParamLocationQuery, *params.Metadata); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err @@ -2538,7 +2680,7 @@ func NewGetImageRequest(server string, name string) (*http.Request, error) { } // NewListIngressesRequest generates requests for ListIngresses -func NewListIngressesRequest(server string) (*http.Request, error) { +func NewListIngressesRequest(server string, params *ListIngressesParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -2556,6 +2698,28 @@ func NewListIngressesRequest(server string) (*http.Request, error) { return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.Metadata != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("deepObject", true, "metadata", runtime.ParamLocationQuery, *params.Metadata); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err @@ -3524,6 +3688,22 @@ func NewListSnapshotsRequest(server string, params *ListSnapshotsParams) (*http. } + if params.Metadata != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("deepObject", true, "metadata", runtime.ParamLocationQuery, *params.Metadata); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + queryURL.RawQuery = queryValues.Encode() } @@ -3651,7 +3831,7 @@ func NewForkSnapshotRequestWithBody(server string, snapshotId string, contentTyp } // NewListVolumesRequest generates requests for ListVolumes -func NewListVolumesRequest(server string) (*http.Request, error) { +func NewListVolumesRequest(server string, params *ListVolumesParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -3669,6 +3849,28 @@ func NewListVolumesRequest(server string) (*http.Request, error) { return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.Metadata != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("deepObject", true, "metadata", runtime.ParamLocationQuery, *params.Metadata); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err @@ -3779,6 +3981,22 @@ func NewCreateVolumeFromArchiveRequestWithBody(server string, params *CreateVolu } + if params.Metadata != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("deepObject", true, "metadata", runtime.ParamLocationQuery, *params.Metadata); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + queryURL.RawQuery = queryValues.Encode() } @@ -3904,7 +4122,7 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { // ListBuildsWithResponse request - ListBuildsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListBuildsResponse, error) + ListBuildsWithResponse(ctx context.Context, params *ListBuildsParams, reqEditors ...RequestEditorFn) (*ListBuildsResponse, error) // CreateBuildWithBodyWithResponse request with any body CreateBuildWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateBuildResponse, error) @@ -3919,7 +4137,7 @@ type ClientWithResponsesInterface interface { GetBuildEventsWithResponse(ctx context.Context, id string, params *GetBuildEventsParams, reqEditors ...RequestEditorFn) (*GetBuildEventsResponse, error) // ListDevicesWithResponse request - ListDevicesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListDevicesResponse, error) + ListDevicesWithResponse(ctx context.Context, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*ListDevicesResponse, error) // CreateDeviceWithBodyWithResponse request with any body CreateDeviceWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateDeviceResponse, error) @@ -3939,7 +4157,7 @@ type ClientWithResponsesInterface interface { GetHealthWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetHealthResponse, error) // ListImagesWithResponse request - ListImagesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListImagesResponse, error) + ListImagesWithResponse(ctx context.Context, params *ListImagesParams, reqEditors ...RequestEditorFn) (*ListImagesResponse, error) // CreateImageWithBodyWithResponse request with any body CreateImageWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateImageResponse, error) @@ -3953,7 +4171,7 @@ type ClientWithResponsesInterface interface { GetImageWithResponse(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*GetImageResponse, error) // ListIngressesWithResponse request - ListIngressesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListIngressesResponse, error) + ListIngressesWithResponse(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*ListIngressesResponse, error) // CreateIngressWithBodyWithResponse request with any body CreateIngressWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateIngressResponse, error) @@ -4044,7 +4262,7 @@ type ClientWithResponsesInterface interface { ForkSnapshotWithResponse(ctx context.Context, snapshotId string, body ForkSnapshotJSONRequestBody, reqEditors ...RequestEditorFn) (*ForkSnapshotResponse, error) // ListVolumesWithResponse request - ListVolumesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListVolumesResponse, error) + ListVolumesWithResponse(ctx context.Context, params *ListVolumesParams, reqEditors ...RequestEditorFn) (*ListVolumesResponse, error) // CreateVolumeWithBodyWithResponse request with any body CreateVolumeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateVolumeResponse, error) @@ -5166,8 +5384,8 @@ func (r GetVolumeResponse) StatusCode() int { } // ListBuildsWithResponse request returning *ListBuildsResponse -func (c *ClientWithResponses) ListBuildsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListBuildsResponse, error) { - rsp, err := c.ListBuilds(ctx, reqEditors...) +func (c *ClientWithResponses) ListBuildsWithResponse(ctx context.Context, params *ListBuildsParams, reqEditors ...RequestEditorFn) (*ListBuildsResponse, error) { + rsp, err := c.ListBuilds(ctx, params, reqEditors...) if err != nil { return nil, err } @@ -5211,8 +5429,8 @@ func (c *ClientWithResponses) GetBuildEventsWithResponse(ctx context.Context, id } // ListDevicesWithResponse request returning *ListDevicesResponse -func (c *ClientWithResponses) ListDevicesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListDevicesResponse, error) { - rsp, err := c.ListDevices(ctx, reqEditors...) +func (c *ClientWithResponses) ListDevicesWithResponse(ctx context.Context, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*ListDevicesResponse, error) { + rsp, err := c.ListDevices(ctx, params, reqEditors...) if err != nil { return nil, err } @@ -5273,8 +5491,8 @@ func (c *ClientWithResponses) GetHealthWithResponse(ctx context.Context, reqEdit } // ListImagesWithResponse request returning *ListImagesResponse -func (c *ClientWithResponses) ListImagesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListImagesResponse, error) { - rsp, err := c.ListImages(ctx, reqEditors...) +func (c *ClientWithResponses) ListImagesWithResponse(ctx context.Context, params *ListImagesParams, reqEditors ...RequestEditorFn) (*ListImagesResponse, error) { + rsp, err := c.ListImages(ctx, params, reqEditors...) if err != nil { return nil, err } @@ -5317,8 +5535,8 @@ func (c *ClientWithResponses) GetImageWithResponse(ctx context.Context, name str } // ListIngressesWithResponse request returning *ListIngressesResponse -func (c *ClientWithResponses) ListIngressesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListIngressesResponse, error) { - rsp, err := c.ListIngresses(ctx, reqEditors...) +func (c *ClientWithResponses) ListIngressesWithResponse(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*ListIngressesResponse, error) { + rsp, err := c.ListIngresses(ctx, params, reqEditors...) if err != nil { return nil, err } @@ -5606,8 +5824,8 @@ func (c *ClientWithResponses) ForkSnapshotWithResponse(ctx context.Context, snap } // ListVolumesWithResponse request returning *ListVolumesResponse -func (c *ClientWithResponses) ListVolumesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListVolumesResponse, error) { - rsp, err := c.ListVolumes(ctx, reqEditors...) +func (c *ClientWithResponses) ListVolumesWithResponse(ctx context.Context, params *ListVolumesParams, reqEditors ...RequestEditorFn) (*ListVolumesResponse, error) { + rsp, err := c.ListVolumes(ctx, params, reqEditors...) if err != nil { return nil, err } @@ -7630,7 +7848,7 @@ func ParseGetVolumeResponse(rsp *http.Response) (*GetVolumeResponse, error) { type ServerInterface interface { // List builds // (GET /builds) - ListBuilds(w http.ResponseWriter, r *http.Request) + ListBuilds(w http.ResponseWriter, r *http.Request, params ListBuildsParams) // Create a new build // (POST /builds) CreateBuild(w http.ResponseWriter, r *http.Request) @@ -7645,7 +7863,7 @@ type ServerInterface interface { GetBuildEvents(w http.ResponseWriter, r *http.Request, id string, params GetBuildEventsParams) // List registered devices // (GET /devices) - ListDevices(w http.ResponseWriter, r *http.Request) + ListDevices(w http.ResponseWriter, r *http.Request, params ListDevicesParams) // Register a device for passthrough // (POST /devices) CreateDevice(w http.ResponseWriter, r *http.Request) @@ -7663,7 +7881,7 @@ type ServerInterface interface { GetHealth(w http.ResponseWriter, r *http.Request) // List images // (GET /images) - ListImages(w http.ResponseWriter, r *http.Request) + ListImages(w http.ResponseWriter, r *http.Request, params ListImagesParams) // Pull and convert OCI image // (POST /images) CreateImage(w http.ResponseWriter, r *http.Request) @@ -7675,7 +7893,7 @@ type ServerInterface interface { GetImage(w http.ResponseWriter, r *http.Request, name string) // List ingresses // (GET /ingresses) - ListIngresses(w http.ResponseWriter, r *http.Request) + ListIngresses(w http.ResponseWriter, r *http.Request, params ListIngressesParams) // Create ingress // (POST /ingresses) CreateIngress(w http.ResponseWriter, r *http.Request) @@ -7750,7 +7968,7 @@ type ServerInterface interface { ForkSnapshot(w http.ResponseWriter, r *http.Request, snapshotId string) // List volumes // (GET /volumes) - ListVolumes(w http.ResponseWriter, r *http.Request) + ListVolumes(w http.ResponseWriter, r *http.Request, params ListVolumesParams) // Create empty volume // (POST /volumes) CreateVolume(w http.ResponseWriter, r *http.Request) @@ -7771,7 +7989,7 @@ type Unimplemented struct{} // List builds // (GET /builds) -func (_ Unimplemented) ListBuilds(w http.ResponseWriter, r *http.Request) { +func (_ Unimplemented) ListBuilds(w http.ResponseWriter, r *http.Request, params ListBuildsParams) { w.WriteHeader(http.StatusNotImplemented) } @@ -7801,7 +8019,7 @@ func (_ Unimplemented) GetBuildEvents(w http.ResponseWriter, r *http.Request, id // List registered devices // (GET /devices) -func (_ Unimplemented) ListDevices(w http.ResponseWriter, r *http.Request) { +func (_ Unimplemented) ListDevices(w http.ResponseWriter, r *http.Request, params ListDevicesParams) { w.WriteHeader(http.StatusNotImplemented) } @@ -7837,7 +8055,7 @@ func (_ Unimplemented) GetHealth(w http.ResponseWriter, r *http.Request) { // List images // (GET /images) -func (_ Unimplemented) ListImages(w http.ResponseWriter, r *http.Request) { +func (_ Unimplemented) ListImages(w http.ResponseWriter, r *http.Request, params ListImagesParams) { w.WriteHeader(http.StatusNotImplemented) } @@ -7861,7 +8079,7 @@ func (_ Unimplemented) GetImage(w http.ResponseWriter, r *http.Request, name str // List ingresses // (GET /ingresses) -func (_ Unimplemented) ListIngresses(w http.ResponseWriter, r *http.Request) { +func (_ Unimplemented) ListIngresses(w http.ResponseWriter, r *http.Request, params ListIngressesParams) { w.WriteHeader(http.StatusNotImplemented) } @@ -8011,7 +8229,7 @@ func (_ Unimplemented) ForkSnapshot(w http.ResponseWriter, r *http.Request, snap // List volumes // (GET /volumes) -func (_ Unimplemented) ListVolumes(w http.ResponseWriter, r *http.Request) { +func (_ Unimplemented) ListVolumes(w http.ResponseWriter, r *http.Request, params ListVolumesParams) { w.WriteHeader(http.StatusNotImplemented) } @@ -8051,14 +8269,27 @@ type MiddlewareFunc func(http.Handler) http.Handler // ListBuilds operation middleware func (siw *ServerInterfaceWrapper) ListBuilds(w http.ResponseWriter, r *http.Request) { + var err error + ctx := r.Context() ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) r = r.WithContext(ctx) + // Parameter object where we will unmarshal all parameters from the context + var params ListBuildsParams + + // ------------- Optional query parameter "metadata" ------------- + + err = runtime.BindQueryParameter("deepObject", true, false, "metadata", r.URL.Query(), ¶ms.Metadata) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "metadata", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListBuilds(w, r) + siw.Handler.ListBuilds(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -8195,14 +8426,27 @@ func (siw *ServerInterfaceWrapper) GetBuildEvents(w http.ResponseWriter, r *http // ListDevices operation middleware func (siw *ServerInterfaceWrapper) ListDevices(w http.ResponseWriter, r *http.Request) { + var err error + ctx := r.Context() ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) r = r.WithContext(ctx) + // Parameter object where we will unmarshal all parameters from the context + var params ListDevicesParams + + // ------------- Optional query parameter "metadata" ------------- + + err = runtime.BindQueryParameter("deepObject", true, false, "metadata", r.URL.Query(), ¶ms.Metadata) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "metadata", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListDevices(w, r) + siw.Handler.ListDevices(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -8331,14 +8575,27 @@ func (siw *ServerInterfaceWrapper) GetHealth(w http.ResponseWriter, r *http.Requ // ListImages operation middleware func (siw *ServerInterfaceWrapper) ListImages(w http.ResponseWriter, r *http.Request) { + var err error + ctx := r.Context() ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) r = r.WithContext(ctx) + // Parameter object where we will unmarshal all parameters from the context + var params ListImagesParams + + // ------------- Optional query parameter "metadata" ------------- + + err = runtime.BindQueryParameter("deepObject", true, false, "metadata", r.URL.Query(), ¶ms.Metadata) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "metadata", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListImages(w, r) + siw.Handler.ListImages(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -8433,14 +8690,27 @@ func (siw *ServerInterfaceWrapper) GetImage(w http.ResponseWriter, r *http.Reque // ListIngresses operation middleware func (siw *ServerInterfaceWrapper) ListIngresses(w http.ResponseWriter, r *http.Request) { + var err error + ctx := r.Context() ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) r = r.WithContext(ctx) + // Parameter object where we will unmarshal all parameters from the context + var params ListIngressesParams + + // ------------- Optional query parameter "metadata" ------------- + + err = runtime.BindQueryParameter("deepObject", true, false, "metadata", r.URL.Query(), ¶ms.Metadata) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "metadata", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListIngresses(w, r) + siw.Handler.ListIngresses(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -9165,6 +9435,14 @@ func (siw *ServerInterfaceWrapper) ListSnapshots(w http.ResponseWriter, r *http. return } + // ------------- Optional query parameter "metadata" ------------- + + err = runtime.BindQueryParameter("deepObject", true, false, "metadata", r.URL.Query(), ¶ms.Metadata) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "metadata", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.ListSnapshots(w, r, params) })) @@ -9272,14 +9550,27 @@ func (siw *ServerInterfaceWrapper) ForkSnapshot(w http.ResponseWriter, r *http.R // ListVolumes operation middleware func (siw *ServerInterfaceWrapper) ListVolumes(w http.ResponseWriter, r *http.Request) { + var err error + ctx := r.Context() ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) r = r.WithContext(ctx) + // Parameter object where we will unmarshal all parameters from the context + var params ListVolumesParams + + // ------------- Optional query parameter "metadata" ------------- + + err = runtime.BindQueryParameter("deepObject", true, false, "metadata", r.URL.Query(), ¶ms.Metadata) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "metadata", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListVolumes(w, r) + siw.Handler.ListVolumes(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -9361,6 +9652,14 @@ func (siw *ServerInterfaceWrapper) CreateVolumeFromArchive(w http.ResponseWriter return } + // ------------- Optional query parameter "metadata" ------------- + + err = runtime.BindQueryParameter("deepObject", true, false, "metadata", r.URL.Query(), ¶ms.Metadata) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "metadata", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.CreateVolumeFromArchive(w, r, params) })) @@ -9687,6 +9986,7 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl } type ListBuildsRequestObject struct { + Params ListBuildsParams } type ListBuildsResponseObject interface { @@ -9889,6 +10189,7 @@ func (response GetBuildEvents500JSONResponse) VisitGetBuildEventsResponse(w http } type ListDevicesRequestObject struct { + Params ListDevicesParams } type ListDevicesResponseObject interface { @@ -10113,6 +10414,7 @@ func (response GetHealth200JSONResponse) VisitGetHealthResponse(w http.ResponseW } type ListImagesRequestObject struct { + Params ListImagesParams } type ListImagesResponseObject interface { @@ -10269,6 +10571,7 @@ func (response GetImage500JSONResponse) VisitGetImageResponse(w http.ResponseWri } type ListIngressesRequestObject struct { + Params ListIngressesParams } type ListIngressesResponseObject interface { @@ -11358,6 +11661,7 @@ func (response ForkSnapshot501JSONResponse) VisitForkSnapshotResponse(w http.Res } type ListVolumesRequestObject struct { + Params ListVolumesParams } type ListVolumesResponseObject interface { @@ -11745,9 +12049,11 @@ type strictHandler struct { } // ListBuilds operation middleware -func (sh *strictHandler) ListBuilds(w http.ResponseWriter, r *http.Request) { +func (sh *strictHandler) ListBuilds(w http.ResponseWriter, r *http.Request, params ListBuildsParams) { var request ListBuildsRequestObject + request.Params = params + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.ListBuilds(ctx, request.(ListBuildsRequestObject)) } @@ -11879,9 +12185,11 @@ func (sh *strictHandler) GetBuildEvents(w http.ResponseWriter, r *http.Request, } // ListDevices operation middleware -func (sh *strictHandler) ListDevices(w http.ResponseWriter, r *http.Request) { +func (sh *strictHandler) ListDevices(w http.ResponseWriter, r *http.Request, params ListDevicesParams) { var request ListDevicesRequestObject + request.Params = params + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.ListDevices(ctx, request.(ListDevicesRequestObject)) } @@ -12034,9 +12342,11 @@ func (sh *strictHandler) GetHealth(w http.ResponseWriter, r *http.Request) { } // ListImages operation middleware -func (sh *strictHandler) ListImages(w http.ResponseWriter, r *http.Request) { +func (sh *strictHandler) ListImages(w http.ResponseWriter, r *http.Request, params ListImagesParams) { var request ListImagesRequestObject + request.Params = params + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.ListImages(ctx, request.(ListImagesRequestObject)) } @@ -12141,9 +12451,11 @@ func (sh *strictHandler) GetImage(w http.ResponseWriter, r *http.Request, name s } // ListIngresses operation middleware -func (sh *strictHandler) ListIngresses(w http.ResponseWriter, r *http.Request) { +func (sh *strictHandler) ListIngresses(w http.ResponseWriter, r *http.Request, params ListIngressesParams) { var request ListIngressesRequestObject + request.Params = params + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.ListIngresses(ctx, request.(ListIngressesRequestObject)) } @@ -12844,9 +13156,11 @@ func (sh *strictHandler) ForkSnapshot(w http.ResponseWriter, r *http.Request, sn } // ListVolumes operation middleware -func (sh *strictHandler) ListVolumes(w http.ResponseWriter, r *http.Request) { +func (sh *strictHandler) ListVolumes(w http.ResponseWriter, r *http.Request, params ListVolumesParams) { var request ListVolumesRequestObject + request.Params = params + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.ListVolumes(ctx, request.(ListVolumesRequestObject)) } @@ -12981,198 +13295,201 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "Uy7VYL+/3299ef/lfwMAAP//KK+tJsQVAQA=", + "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", + "qQaH/cN+68v7L/8/AAD//9ibNZqCHgEA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/snapshot/store_test.go b/lib/snapshot/store_test.go index e2ae3eec..10e485cb 100644 --- a/lib/snapshot/store_test.go +++ b/lib/snapshot/store_test.go @@ -24,6 +24,7 @@ func TestStoreSaveLoadListDelete(t *testing.T) { Id: "snap1", Name: "baseline", Kind: SnapshotKindStandby, + Metadata: map[string]string{"team": "backend", "env": "staging"}, SourceInstanceID: "inst1", SourceName: "vm1", SourceHypervisor: hypervisor.TypeQEMU, @@ -38,6 +39,7 @@ func TestStoreSaveLoadListDelete(t *testing.T) { got, err := store.LoadRecord(record.Snapshot.Id) require.NoError(t, err) require.Equal(t, record.Snapshot.Id, got.Snapshot.Id) + require.Equal(t, record.Snapshot.Metadata, got.Snapshot.Metadata) require.JSONEq(t, string(record.StoredMetadata), string(got.StoredMetadata)) listed, err := store.List(nil) @@ -85,17 +87,26 @@ func TestListSnapshotsFilterMatches(t *testing.T) { SourceInstanceID: &sourceID, Kind: &kind, Name: &name, + Metadata: map[string]string{"team": "backend", "env": "staging"}, } require.True(t, filter.Matches(&Snapshot{ SourceInstanceID: "inst1", Kind: SnapshotKindStandby, Name: "snap", + Metadata: map[string]string{"team": "backend", "env": "staging"}, })) require.False(t, filter.Matches(&Snapshot{ SourceInstanceID: "inst2", Kind: SnapshotKindStandby, Name: "snap", + Metadata: map[string]string{"team": "backend", "env": "staging"}, + })) + require.False(t, filter.Matches(&Snapshot{ + SourceInstanceID: "inst1", + Kind: SnapshotKindStandby, + Name: "snap", + Metadata: map[string]string{"team": "backend", "env": "prod"}, })) } diff --git a/lib/snapshot/types.go b/lib/snapshot/types.go index 38ed6619..ef2bd8a8 100644 --- a/lib/snapshot/types.go +++ b/lib/snapshot/types.go @@ -4,6 +4,7 @@ import ( "time" "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/tags" ) // SnapshotKind determines how snapshot data is captured and restored. @@ -18,11 +19,12 @@ const ( // Snapshot is a centrally stored immutable snapshot resource. type Snapshot struct { - Id string `json:"id"` - Name string `json:"name"` - Kind SnapshotKind `json:"kind"` - SourceInstanceID string `json:"source_instance_id"` - SourceName string `json:"source_instance_name"` + Id string `json:"id"` + Name string `json:"name"` + Kind SnapshotKind `json:"kind"` + Metadata tags.Metadata `json:"metadata,omitempty"` + SourceInstanceID string `json:"source_instance_id"` + SourceName string `json:"source_instance_name"` SourceHypervisor hypervisor.Type CreatedAt time.Time `json:"created_at"` SizeBytes int64 `json:"size_bytes"` @@ -33,6 +35,7 @@ type ListSnapshotsFilter struct { SourceInstanceID *string Kind *SnapshotKind Name *string + Metadata tags.Metadata } // Matches returns true if the given snapshot satisfies all filter criteria. @@ -49,5 +52,8 @@ func (f *ListSnapshotsFilter) Matches(snapshot *Snapshot) bool { if f.Name != nil && snapshot.Name != *f.Name { return false } + if !tags.Matches(snapshot.Metadata, f.Metadata) { + return false + } return true } diff --git a/lib/tags/README.md b/lib/tags/README.md new file mode 100644 index 00000000..f02e1639 --- /dev/null +++ b/lib/tags/README.md @@ -0,0 +1,35 @@ +# Metadata Tags + +This package defines the product contract for user-provided metadata tags used across mutable Hypeman resources. + +## What Tags Are + +Metadata tags are optional string key/value pairs that let users label resources for ownership, environment, automation, and filtering. + +Domain code uses the shared `tags.Metadata` type alias so resource packages reference one metadata type consistently. + +Examples: +- `team=backend` +- `env=staging` +- `cost_center=ml-platform` + +## Contract + +- Type: `map[string]string` +- Maximum entries per resource: `50` +- Key length: `1..128` +- Value length: `0..256` +- Allowed characters in keys/values: `[A-Za-z0-9 _.:/=+@-]` + +## Behavior + +- Tags are optional. +- Tag filters use exact, case-sensitive matching. +- Multiple filter pairs are ANDed together. +- Resources with no tags do not match non-empty tag filters. + +## API Expectations + +- Create endpoints accept optional `metadata`. +- Get/List responses include `metadata` when present. +- List endpoints support filtering by `metadata[...]` where enabled. diff --git a/lib/tags/clone.go b/lib/tags/clone.go new file mode 100644 index 00000000..fbe9b709 --- /dev/null +++ b/lib/tags/clone.go @@ -0,0 +1,13 @@ +package tags + +// Clone returns a deep copy of metadata map and normalizes empty maps to nil. +func Clone(metadata Metadata) Metadata { + if len(metadata) == 0 { + return nil + } + out := make(Metadata, len(metadata)) + for k, v := range metadata { + out[k] = v + } + return out +} diff --git a/lib/tags/clone_test.go b/lib/tags/clone_test.go new file mode 100644 index 00000000..ae9ddd1a --- /dev/null +++ b/lib/tags/clone_test.go @@ -0,0 +1,21 @@ +package tags + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestClone(t *testing.T) { + t.Parallel() + + require.Nil(t, Clone(nil)) + require.Nil(t, Clone(map[string]string{})) + + in := map[string]string{"team": "backend"} + out := Clone(in) + require.Equal(t, in, out) + + out["team"] = "frontend" + require.Equal(t, "backend", in["team"]) +} diff --git a/lib/tags/constraints.go b/lib/tags/constraints.go new file mode 100644 index 00000000..9b8daed2 --- /dev/null +++ b/lib/tags/constraints.go @@ -0,0 +1,14 @@ +package tags + +import "regexp" + +const ( + MaxEntries = 50 + MinKeyLength = 1 + MaxKeyLength = 128 + MinValueLength = 0 + MaxValueLength = 256 +) + +// Allowed characters are aligned with the AWS-like strict metadata contract. +var allowedPattern = regexp.MustCompile(`^[A-Za-z0-9 _.:/=+@-]+$`) diff --git a/lib/tags/errors.go b/lib/tags/errors.go new file mode 100644 index 00000000..b7db599c --- /dev/null +++ b/lib/tags/errors.go @@ -0,0 +1,5 @@ +package tags + +import "errors" + +var ErrInvalidMetadata = errors.New("invalid metadata") diff --git a/lib/tags/match.go b/lib/tags/match.go new file mode 100644 index 00000000..e2b617f5 --- /dev/null +++ b/lib/tags/match.go @@ -0,0 +1,18 @@ +package tags + +// Matches returns true when resource metadata satisfies all filter pairs. +func Matches(resource Metadata, filter Metadata) bool { + if len(filter) == 0 { + return true + } + if len(resource) == 0 { + return false + } + for k, v := range filter { + actual, ok := resource[k] + if !ok || actual != v { + return false + } + } + return true +} diff --git a/lib/tags/match_test.go b/lib/tags/match_test.go new file mode 100644 index 00000000..a14db901 --- /dev/null +++ b/lib/tags/match_test.go @@ -0,0 +1,21 @@ +package tags + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMatches(t *testing.T) { + t.Parallel() + + resource := map[string]string{"team": "backend", "env": "prod"} + + require.True(t, Matches(resource, nil)) + require.True(t, Matches(resource, map[string]string{})) + require.True(t, Matches(resource, map[string]string{"team": "backend"})) + require.True(t, Matches(resource, map[string]string{"team": "backend", "env": "prod"})) + require.False(t, Matches(resource, map[string]string{"team": "frontend"})) + require.False(t, Matches(nil, map[string]string{"team": "backend"})) + require.False(t, Matches(map[string]string{}, map[string]string{"team": "backend"})) +} diff --git a/lib/tags/types.go b/lib/tags/types.go new file mode 100644 index 00000000..032b47c1 --- /dev/null +++ b/lib/tags/types.go @@ -0,0 +1,4 @@ +package tags + +// Metadata is the shared domain type for resource metadata tags. +type Metadata = map[string]string diff --git a/lib/tags/validate.go b/lib/tags/validate.go new file mode 100644 index 00000000..4888b685 --- /dev/null +++ b/lib/tags/validate.go @@ -0,0 +1,46 @@ +package tags + +import ( + "fmt" + "sort" + "unicode/utf8" +) + +// Validate enforces metadata constraints for all mutable resources. +func Validate(metadata Metadata) error { + if len(metadata) == 0 { + return nil + } + + if len(metadata) > MaxEntries { + return fmt.Errorf("%w: too many entries: %d (max %d)", ErrInvalidMetadata, len(metadata), MaxEntries) + } + + keys := make([]string, 0, len(metadata)) + for key := range metadata { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + value := metadata[key] + + keyLen := utf8.RuneCountInString(key) + if keyLen < MinKeyLength || keyLen > MaxKeyLength { + return fmt.Errorf("%w: key %q length %d out of range [%d,%d]", ErrInvalidMetadata, key, keyLen, MinKeyLength, MaxKeyLength) + } + if !allowedPattern.MatchString(key) { + return fmt.Errorf("%w: key %q contains unsupported characters", ErrInvalidMetadata, key) + } + + valueLen := utf8.RuneCountInString(value) + if valueLen < MinValueLength || valueLen > MaxValueLength { + return fmt.Errorf("%w: value for key %q length %d out of range [%d,%d]", ErrInvalidMetadata, key, valueLen, MinValueLength, MaxValueLength) + } + if valueLen > 0 && !allowedPattern.MatchString(value) { + return fmt.Errorf("%w: value for key %q contains unsupported characters", ErrInvalidMetadata, key) + } + } + + return nil +} diff --git a/lib/tags/validate_test.go b/lib/tags/validate_test.go new file mode 100644 index 00000000..45307ba3 --- /dev/null +++ b/lib/tags/validate_test.go @@ -0,0 +1,48 @@ +package tags + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidate(t *testing.T) { + t.Parallel() + + require.NoError(t, Validate(nil)) + require.NoError(t, Validate(map[string]string{})) + require.NoError(t, Validate(map[string]string{"team": "backend", "desc": ""})) + require.NoError(t, Validate(map[string]string{"a+b": "x/y:z@w="})) + + err := Validate(map[string]string{"": "x"}) + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidMetadata) + + err = Validate(map[string]string{"tēam": "backend"}) + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidMetadata) + + err = Validate(map[string]string{"team": "支付"}) + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidMetadata) + + tooMany := make(map[string]string, MaxEntries+1) + for i := 0; i < MaxEntries+1; i++ { + tooMany[fmt.Sprintf("k%d", i)] = "v" + } + err = Validate(tooMany) + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidMetadata) + + longKey := map[string]string{strings.Repeat("a", MaxKeyLength+1): "v"} + err = Validate(longKey) + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidMetadata) + + longValue := map[string]string{"key": strings.Repeat("a", MaxValueLength+1)} + err = Validate(longValue) + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidMetadata) +} diff --git a/lib/volumes/manager.go b/lib/volumes/manager.go index 6f3f16d8..f644960e 100644 --- a/lib/volumes/manager.go +++ b/lib/volumes/manager.go @@ -10,6 +10,7 @@ import ( "github.com/kernel/hypeman/lib/images" "github.com/kernel/hypeman/lib/paths" + "github.com/kernel/hypeman/lib/tags" "github.com/nrednav/cuid2" "go.opentelemetry.io/otel/metric" ) @@ -110,6 +111,9 @@ func (m *manager) calculateTotalVolumeStorage(ctx context.Context) (int64, error // CreateVolume creates a new volume func (m *manager) CreateVolume(ctx context.Context, req CreateVolumeRequest) (*Volume, error) { start := time.Now() + if err := tags.Validate(req.Metadata); err != nil { + return nil, err + } // Generate or use provided ID id := cuid2.Generate() @@ -154,6 +158,7 @@ func (m *manager) CreateVolume(ctx context.Context, req CreateVolumeRequest) (*V Id: id, Name: req.Name, SizeGb: req.SizeGb, + Metadata: tags.Clone(req.Metadata), CreatedAt: now.Format(time.RFC3339), } @@ -172,6 +177,9 @@ func (m *manager) CreateVolume(ctx context.Context, req CreateVolumeRequest) (*V // The archive is safely extracted with size limits to prevent tar bombs. func (m *manager) CreateVolumeFromArchive(ctx context.Context, req CreateVolumeFromArchiveRequest, archive io.Reader) (*Volume, error) { start := time.Now() + if err := tags.Validate(req.Metadata); err != nil { + return nil, err + } // Generate or use provided ID id := cuid2.Generate() @@ -236,6 +244,7 @@ func (m *manager) CreateVolumeFromArchive(ctx context.Context, req CreateVolumeF Id: id, Name: req.Name, SizeGb: actualSizeGb, + Metadata: tags.Clone(req.Metadata), CreatedAt: now.Format(time.RFC3339), } @@ -420,6 +429,7 @@ func (m *manager) metadataToVolume(meta *storedMetadata) *Volume { Id: meta.Id, Name: meta.Name, SizeGb: meta.SizeGb, + Metadata: tags.Clone(meta.Metadata), CreatedAt: createdAt, Attachments: attachments, } diff --git a/lib/volumes/manager_test.go b/lib/volumes/manager_test.go index e63fa58a..69c4e7c5 100644 --- a/lib/volumes/manager_test.go +++ b/lib/volumes/manager_test.go @@ -389,3 +389,31 @@ func TestMultiAttach_ConcurrentRWConflict(t *testing.T) { assert.Len(t, vol.Attachments, 1, "Should have exactly one attachment") assert.False(t, vol.Attachments[0].Readonly, "Attachment should be read-write") } + +func TestCreateVolume_MetadataRoundTrip(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "volume-metadata-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + p := paths.New(tmpDir) + meta := &storedMetadata{ + Id: "vol-metadata-1", + Name: "tagged-vol", + SizeGb: 10, + Attachments: []storedAttachment{}, + Metadata: map[string]string{"team": "backend", "env": "staging"}, + } + require.NoError(t, os.MkdirAll(p.VolumeDir(meta.Id), 0755)) + require.NoError(t, saveMetadata(p, meta)) + + loaded, err := loadMetadata(p, meta.Id) + require.NoError(t, err) + require.Equal(t, map[string]string{"team": "backend", "env": "staging"}, loaded.Metadata) + + vol := (&manager{}).metadataToVolume(loaded) + require.Equal(t, map[string]string{"team": "backend", "env": "staging"}, vol.Metadata) + + // Verify deep-copy behavior from persisted metadata. + loaded.Metadata["team"] = "mutated" + require.Equal(t, "backend", vol.Metadata["team"]) +} diff --git a/lib/volumes/storage.go b/lib/volumes/storage.go index ccca55ed..7709e659 100644 --- a/lib/volumes/storage.go +++ b/lib/volumes/storage.go @@ -8,6 +8,7 @@ import ( "github.com/kernel/hypeman/lib/images" "github.com/kernel/hypeman/lib/paths" + "github.com/kernel/hypeman/lib/tags" ) // Filesystem structure: @@ -27,6 +28,7 @@ type storedMetadata struct { Id string `json:"id"` Name string `json:"name"` SizeGb int `json:"size_gb"` + Metadata tags.Metadata `json:"metadata,omitempty"` CreatedAt string `json:"created_at"` // RFC3339 format Attachments []storedAttachment `json:"attachments,omitempty"` } diff --git a/lib/volumes/types.go b/lib/volumes/types.go index 4235b169..2ddac750 100644 --- a/lib/volumes/types.go +++ b/lib/volumes/types.go @@ -1,6 +1,10 @@ package volumes -import "time" +import ( + "time" + + "github.com/kernel/hypeman/lib/tags" +) // Attachment represents a volume attached to an instance type Attachment struct { @@ -14,15 +18,17 @@ type Volume struct { Id string Name string SizeGb int + Metadata tags.Metadata CreatedAt time.Time Attachments []Attachment // List of current attachments (empty if not attached) } // CreateVolumeRequest is the domain request for creating a volume type CreateVolumeRequest struct { - Name string - SizeGb int - Id *string // Optional custom ID + Name string + SizeGb int + Id *string // Optional custom ID + Metadata tags.Metadata } // AttachVolumeRequest is the domain request for attaching a volume to an instance @@ -35,7 +41,8 @@ type AttachVolumeRequest struct { // CreateVolumeFromArchiveRequest is the domain request for creating a volume // pre-populated with content from a tar.gz archive type CreateVolumeFromArchiveRequest struct { - Name string - SizeGb int // Maximum size in GB (extraction fails if content exceeds this) - Id *string // Optional custom ID + Name string + SizeGb int // Maximum size in GB (extraction fails if content exceeds this) + Id *string // Optional custom ID + Metadata tags.Metadata } diff --git a/openapi.yaml b/openapi.yaml index 7d09d015..ea6a3190 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -97,6 +97,24 @@ components: type: string enum: [tcp, udp] default: tcp + + MetadataTags: + type: object + maxProperties: 50 + description: User-defined key-value metadata tags. + propertyNames: + type: string + minLength: 1 + maxLength: 128 + pattern: ^[A-Za-z0-9 _.:/=+@-]+$ + additionalProperties: + type: string + minLength: 0 + maxLength: 256 + pattern: ^[A-Za-z0-9 _.:/=+@-]*$ + example: + team: backend + env: staging CreateInstanceRequest: type: object @@ -144,13 +162,7 @@ components: PORT: "3000" NODE_ENV: production metadata: - type: object - additionalProperties: - type: string - description: User-defined key-value metadata for the instance - example: - team: backend - purpose: staging + $ref: "#/components/schemas/MetadataTags" network: type: object description: Network configuration for the instance @@ -274,6 +286,8 @@ components: example: baseline-standby kind: $ref: "#/components/schemas/SnapshotKind" + metadata: + $ref: "#/components/schemas/MetadataTags" source_instance_id: type: string description: Source instance ID at snapshot creation time @@ -310,6 +324,8 @@ components: pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ maxLength: 63 example: pre-upgrade + metadata: + $ref: "#/components/schemas/MetadataTags" RestoreSnapshotRequest: type: object @@ -403,10 +419,7 @@ components: type: string description: Environment variables metadata: - type: object - additionalProperties: - type: string - description: User-defined key-value metadata + $ref: "#/components/schemas/MetadataTags" network: type: object description: Network configuration of the instance @@ -582,6 +595,8 @@ components: type: string description: OCI image reference (e.g., docker.io/library/nginx:latest) example: docker.io/library/nginx:latest + metadata: + $ref: "#/components/schemas/MetadataTags" Image: type: object @@ -637,6 +652,8 @@ components: description: Environment variables from container metadata example: PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + metadata: + $ref: "#/components/schemas/MetadataTags" working_dir: type: string description: Working directory from container metadata @@ -664,6 +681,8 @@ components: type: integer description: Size in gigabytes example: 10 + metadata: + $ref: "#/components/schemas/MetadataTags" VolumeAttachment: type: object @@ -698,6 +717,8 @@ components: type: integer description: Size in gigabytes example: 10 + metadata: + $ref: "#/components/schemas/MetadataTags" attachments: type: array description: List of current attachments (empty if not attached) @@ -803,6 +824,8 @@ components: items: $ref: "#/components/schemas/IngressRule" minItems: 1 + metadata: + $ref: "#/components/schemas/MetadataTags" Ingress: type: object @@ -816,6 +839,8 @@ components: type: string description: Human-readable name example: my-api-ingress + metadata: + $ref: "#/components/schemas/MetadataTags" rules: type: array description: Routing rules for this ingress @@ -929,6 +954,8 @@ components: type: string description: PCI address of the device (required, e.g., "0000:a2:00.0") example: "0000:a2:00.0" + metadata: + $ref: "#/components/schemas/MetadataTags" Device: type: object @@ -944,6 +971,8 @@ components: example: l4-gpu type: $ref: "#/components/schemas/DeviceType" + metadata: + $ref: "#/components/schemas/MetadataTags" pci_address: type: string description: PCI address @@ -1089,6 +1118,8 @@ components: example: "build-abc123" status: $ref: "#/components/schemas/BuildStatus" + metadata: + $ref: "#/components/schemas/MetadataTags" queue_position: type: integer description: Position in build queue (only when status is queued) @@ -1300,6 +1331,15 @@ paths: operationId: listImages security: - bearerAuth: [] + parameters: + - name: metadata + in: query + required: false + style: deepObject + explode: true + schema: + $ref: "#/components/schemas/MetadataTags" + description: Filter images by metadata key-value pairs. responses: 200: description: List of images @@ -1443,9 +1483,7 @@ paths: style: deepObject explode: true schema: - type: object - additionalProperties: - type: string + $ref: "#/components/schemas/MetadataTags" description: | Filter instances by metadata key-value pairs. Uses deepObject style: ?metadata[team]=backend&metadata[env]=staging @@ -1854,6 +1892,14 @@ paths: schema: type: string description: Filter snapshots by snapshot name + - name: metadata + in: query + required: false + style: deepObject + explode: true + schema: + $ref: "#/components/schemas/MetadataTags" + description: Filter snapshots by metadata key-value pairs. responses: 200: description: List of snapshots @@ -2338,6 +2384,15 @@ paths: operationId: listVolumes security: - bearerAuth: [] + parameters: + - name: metadata + in: query + required: false + style: deepObject + explode: true + schema: + $ref: "#/components/schemas/MetadataTags" + description: Filter volumes by metadata key-value pairs. responses: 200: description: List of volumes @@ -2434,6 +2489,14 @@ paths: type: string description: Optional custom volume ID (auto-generated if not provided) example: vol-data-1 + - name: metadata + in: query + required: false + style: deepObject + explode: true + schema: + $ref: "#/components/schemas/MetadataTags" + description: Metadata tags for the created volume. requestBody: required: true description: tar.gz archive file containing the volume content @@ -2546,6 +2609,15 @@ paths: operationId: listDevices security: - bearerAuth: [] + parameters: + - name: metadata + in: query + required: false + style: deepObject + explode: true + schema: + $ref: "#/components/schemas/MetadataTags" + description: Filter devices by metadata key-value pairs. responses: 200: description: List of registered devices @@ -2716,6 +2788,15 @@ paths: operationId: listIngresses security: - bearerAuth: [] + parameters: + - name: metadata + in: query + required: false + style: deepObject + explode: true + schema: + $ref: "#/components/schemas/MetadataTags" + description: Filter ingresses by metadata key-value pairs. responses: 200: description: List of ingresses @@ -2858,6 +2939,15 @@ paths: operationId: listBuilds security: - bearerAuth: [] + parameters: + - name: metadata + in: query + required: false + style: deepObject + explode: true + schema: + $ref: "#/components/schemas/MetadataTags" + description: Filter builds by metadata key-value pairs. responses: 200: description: List of builds @@ -2940,6 +3030,11 @@ paths: Global cache identifier (e.g., "node", "python", "ubuntu", "browser"). When specified, the build will import from cache/global/{key}. Admin builds will also export to this location. + metadata: + type: string + description: | + JSON object of metadata tags. + Example: {"team":"backend","env":"staging"} responses: 202: description: Build created and queued