Skip to content

Commit

Permalink
Merge pull request #593 from sapcc/liquid-ironic
Browse files Browse the repository at this point in the history
add liquid-ironic
  • Loading branch information
majewsky authored Nov 5, 2024
2 parents 4db617d + 284ccfe commit 4ff6ee3
Show file tree
Hide file tree
Showing 17 changed files with 1,029 additions and 97 deletions.
1 change: 1 addition & 0 deletions docs/liquids/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ If the service does not provide LIQUID support itself, you can use one of the li
- [`cinder`](./cinder.md) for the block storage service Cinder
- [`cronus`](./cronus.md) for the email service Cronus (SAP Converged Cloud internal only)
- [`designate`](./designate.md) for the DNS service Designate
- [`ironic`](./ironic.md) for the baremetal compute service Ironic
- [`manila`](./manila.md) for the shared file system storage service Manila
- [`neutron`](./neutron.md) for the networking service Neutron
- [`octavia`](./octavia.md) for the loadbalancing service Octavia
Expand Down
69 changes: 69 additions & 0 deletions docs/liquids/ironic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Liquid: `ironic`

This liquid provides support for the baremetal compute service Ironic.

- The suggested service type is `liquid-ironic`.
- The suggested area is `compute`.

## Service-specific configuration

| Field | Type | Description |
| ----- | ---- | ----------- |
| `with_subcapacities` | boolean | If true, subcapacities are reported. |
| `with_subresources` | boolean | If true, subresources are reported. |

## Resources

There is one resource for each Nova flavor that is used for baremetal deployments using nodes managed by Ironic:

| Resource | Unit | Capabilities |
| -------- | ---- | ------------ |
| `instances_$FLAVOR` | None | HasCapacity = true, HasQuota = true |

Each of these resources carries the following attributes:

| Field | Type | Description |
| ----- | ---- | ----------- |
| `attributes.cores` | integer | Number of CPU cores in this flavor. |
| `attributes.ram_mib` | integer | Amount of RAM in this flavor in MiB. |
| `attributes.disk_gib` | integer | Amount of local disk in this flavor in GiB. |

If `with_subresources` is set, each `instances_$FLAVOR` resource will have one subresource for each instance of that flavor, with the following fields:

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | The UUID of the Nova instance. |
| `name` | string | The human-readable name of the Nova instance. |
| `attributes.status` | string | The status of the instance according to Nova. |
| `attributes.metadata` | object of strings | User-supplied key-value data on this instance according to Nova. |
| `attributes.tags` | array of strings | User-supplied tags on this instance according to Nova. |
| `attributes.os_type` | string | The OS type, as inferred from the image that was used to boot this instance. |

TODO: `os_type` inference is shared with Nova. When the Nova subresource scraping is moved to LIQUID, the method shall be documented over there, and a backreference shall be added here.

### Considerations for cloud operators

This liquid will consider all flavors that have the extra spec `capabilities:hypervisor_type = "ironic"` in Nova.
You need to make sure that the extra specs on your Ironic flavors are all set up in this way.

Furthermore, Nova needs to be patched to ignore the usual quotas for instances of Ironic flavors.
Instead, Nova must accept quotas with the same naming pattern (`instances_$FLAVOR`), and only enforce these quotas when accepting new instances using Ironic flavors, without counting those instances towards the usual quotas.
In SAP Converged Cloud, Nova carries a custom patch set that triggers this behavior on presence of the `quota:instance_only` and `quota:separate` extra specs.

## Capacity calculation

If `with_subcapacities` is set, each `instances_$FLAVOR` resource will have one subcapacity for each matching baremetal server, with the following fields:

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | The UUID of the Ironic node. |
| `name` | string | The human-readable name (usually the hostname) of this node in Ironic. |
| `attributes.provision_state` | string | The `provision_state` attribute of this node in Ironic. |
| `attributes.target_provision_state` | string | The `target_provision_state` attribute of this node in Ironic, if any. |
| `attributes.serial_number` | string | The `properties.serial` attribute of this node in Ironic, if any. |
| `attributes.instance_id` | string | The UUID of the Nova instance running on this node, if any. |

### Considerations for cloud operators

Capacity for each baremetal flavor is counted by finding Ironic nodes that have the flavor name as their `resource_class`.
You need to make sure that your resource classes are set up in this way in the Placement API.
196 changes: 173 additions & 23 deletions internal/api/translation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ import (
"net/http"
"testing"

"github.com/sapcc/go-api-declarations/liquid"
"github.com/sapcc/go-bits/assert"
"github.com/sapcc/go-bits/must"

"github.com/sapcc/limes/internal/core"
"github.com/sapcc/limes/internal/test"
"github.com/sapcc/limes/internal/test/plugins"
)

const (
Expand All @@ -42,19 +44,12 @@ const (
`
)

func TestTranslateManilaSubcapacities(t *testing.T) {
s := test.NewSetup(t,
test.WithDBFixtureFile("fixtures/start-data-small.sql"),
test.WithConfig(testSmallConfigYAML),
test.WithAPIHandler(NewV1API),
)
s.Cluster.Config.ResourceBehaviors = []core.ResourceBehavior{{
FullResourceNameRx: "first/capacity",
TranslationRuleInV1API: must.Return(core.NewTranslationRule("cinder-manila-capacity")),
}}
///////////////////////////////////////////////////////////////////////////////////////////
// subcapacity translation

func TestTranslateManilaSubcapacities(t *testing.T) {
// this is what liquid-manila (or liquid-cinder) writes into the DB
newFormatSubcapacities := []assert.JSONObject{
subcapacitiesInLiquidFormat := []assert.JSONObject{
{
"name": "pool1",
"capacity": 520,
Expand All @@ -71,15 +66,9 @@ func TestTranslateManilaSubcapacities(t *testing.T) {
"attributes": assert.JSONObject{},
},
}
_, err := s.DB.Exec(`UPDATE cluster_az_resources SET subcapacities = $1 WHERE id = 2`,
string(must.Return(json.Marshal(newFormatSubcapacities))),
)
if err != nil {
t.Fatal(err.Error())
}

// this is what we expect to be reported on the API
oldFormatSubcapacities := []assert.JSONObject{
subcapacitiesInLegacyFormat := []assert.JSONObject{
{
"pool_name": "pool1",
"az": "az-one",
Expand All @@ -96,6 +85,94 @@ func TestTranslateManilaSubcapacities(t *testing.T) {
},
}

testSubcapacityTranslation(t, "cinder-manila-capacity", nil, subcapacitiesInLiquidFormat, subcapacitiesInLegacyFormat)
}

func TestTranslateIronicSubcapacities(t *testing.T) {
extraSetup := func(s *test.Setup) {
// this subcapacity translation depends on ResourceInfo.Attributes on the respective resource
plugin := s.Cluster.QuotaPlugins["first"].(*plugins.GenericQuotaPlugin) //nolint:errcheck // it's okay to crash here on type mismatch
plugin.StaticResourceAttributes = map[liquid.ResourceName]map[string]any{"capacity": {
"cores": 5,
"ram_mib": 23,
"disk_gib": 42,
}}
}

subcapacitiesInLiquidFormat := []assert.JSONObject{
{
"id": "c28b2abb-0da6-4b37-81f6-5ae255d53e1f",
"name": "node001",
"capacity": 1,
"attributes": assert.JSONObject{
"provision_state": "AVAILABLE",
"serial_number": "98105291",
},
},
{
"id": "6f8c1838-42db-4d7e-b3c0-98f3bc59fd62",
"name": "node002",
"capacity": 1,
"attributes": assert.JSONObject{
"provision_state": "DEPLOYING",
"target_provision_state": "ACTIVE",
"serial_number": "98105292",
"instance_id": "1bb45c1a-e10f-449a-abf6-1ffc6c93e113",
},
},
}

subcapacitiesInLegacyFormat := []assert.JSONObject{
{
"id": "c28b2abb-0da6-4b37-81f6-5ae255d53e1f",
"name": "node001",
"provision_state": "AVAILABLE",
"availability_zone": "az-one",
"ram": assert.JSONObject{"value": 23, "unit": "MiB"},
"disk": assert.JSONObject{"value": 42, "unit": "GiB"},
"cores": 5,
"serial": "98105291",
},
{
"id": "6f8c1838-42db-4d7e-b3c0-98f3bc59fd62",
"name": "node002",
"provision_state": "DEPLOYING",
"target_provision_state": "ACTIVE",
"availability_zone": "az-one",
"ram": assert.JSONObject{"value": 23, "unit": "MiB"},
"disk": assert.JSONObject{"value": 42, "unit": "GiB"},
"cores": 5,
"serial": "98105292",
"instance_id": "1bb45c1a-e10f-449a-abf6-1ffc6c93e113",
},
}

testSubcapacityTranslation(t, "ironic-flavors", extraSetup, subcapacitiesInLiquidFormat, subcapacitiesInLegacyFormat)
}

func testSubcapacityTranslation(t *testing.T, ruleID string, extraSetup func(s *test.Setup), subcapacitiesInLiquidFormat, subcapacitiesInLegacyFormat []assert.JSONObject) {
s := test.NewSetup(t,
test.WithDBFixtureFile("fixtures/start-data-small.sql"),
test.WithConfig(testSmallConfigYAML),
test.WithAPIHandler(NewV1API),
)
s.Cluster.Config.ResourceBehaviors = []core.ResourceBehavior{{
FullResourceNameRx: "first/capacity",
TranslationRuleInV1API: must.Return(core.NewTranslationRule(ruleID)),
}}

if extraSetup != nil {
extraSetup(&s)
}

// this is what liquid-manila (or liquid-cinder) writes into the DB
_, err := s.DB.Exec(`UPDATE cluster_az_resources SET subcapacities = $1 WHERE id = 2`,
string(must.Return(json.Marshal(subcapacitiesInLiquidFormat))),
)
if err != nil {
t.Fatal(err.Error())
}

assert.HTTPRequest{
Method: "GET",
Path: "/v1/clusters/current?resource=capacity&detail",
Expand All @@ -122,18 +199,21 @@ func TestTranslateManilaSubcapacities(t *testing.T) {
{"capacity": 0, "name": "az-two"},
},
"per_az": assert.JSONObject{
"az-one": assert.JSONObject{"capacity": 0, "usage": 0, "subcapacities": oldFormatSubcapacities},
"az-one": assert.JSONObject{"capacity": 0, "usage": 0, "subcapacities": subcapacitiesInLegacyFormat},
"az-two": assert.JSONObject{"capacity": 0, "usage": 0},
},
"quota_distribution_model": "autogrow",
"subcapacities": oldFormatSubcapacities,
"subcapacities": subcapacitiesInLegacyFormat,
}},
}},
},
},
}.Check(t, s.Handler)
}

///////////////////////////////////////////////////////////////////////////////////////////
// subresource translation

func TestTranslateCinderVolumeSubresources(t *testing.T) {
subresourcesInLiquidFormat := []assert.JSONObject{
{
Expand Down Expand Up @@ -171,7 +251,7 @@ func TestTranslateCinderVolumeSubresources(t *testing.T) {
},
}

testSubresourceTranslation(t, "cinder-volumes", subresourcesInLiquidFormat, subresourcesInLegacyFormat)
testSubresourceTranslation(t, "cinder-volumes", nil, subresourcesInLiquidFormat, subresourcesInLegacyFormat)
}

func TestTranslateCinderSnapshotSubresources(t *testing.T) {
Expand All @@ -197,10 +277,76 @@ func TestTranslateCinderSnapshotSubresources(t *testing.T) {
},
}

testSubresourceTranslation(t, "cinder-snapshots", subresourcesInLiquidFormat, subresourcesInLegacyFormat)
testSubresourceTranslation(t, "cinder-snapshots", nil, subresourcesInLiquidFormat, subresourcesInLegacyFormat)
}

func TestTranslateIronicSubresources(t *testing.T) {
extraSetup := func(s *test.Setup) {
// this subcapacity translation depends on ResourceInfo.Attributes on the respective resource
plugin := s.Cluster.QuotaPlugins["first"].(*plugins.GenericQuotaPlugin) //nolint:errcheck // it's okay to crash here on type mismatch
plugin.StaticResourceAttributes = map[liquid.ResourceName]map[string]any{"capacity": {
"cores": 5,
"ram_mib": 23,
"disk_gib": 42,
}}
}

subresourcesInLiquidFormat := []assert.JSONObject{
{
"id": "7c84fbdb-9a18-43b4-be3e-d45c267d821b",
"name": "minimal-instance",
"attributes": assert.JSONObject{
"status": "ACTIVE",
"os_type": "rhel9",
},
},
{
"id": "248bbfcc-e2cd-4ccc-9782-f2a8050da612",
"name": "maximal-instance",
"attributes": assert.JSONObject{
"status": "ACTIVE",
"metadata": assert.JSONObject{"foo": "bar"},
"tags": []string{"foobar"},
"os_type": "image-deleted",
},
},
}

subresourcesInLegacyFormat := []assert.JSONObject{
{
"id": "7c84fbdb-9a18-43b4-be3e-d45c267d821b",
"name": "minimal-instance",
"status": "ACTIVE",
"metadata": nil,
"tags": nil,
"availability_zone": "az-one",
"hypervisor": "none",
"flavor": "capacity", // this is derived from the resource name, so it looks weird in this test
"vcpu": 5,
"ram": assert.JSONObject{"value": 23, "unit": "MiB"},
"disk": assert.JSONObject{"value": 42, "unit": "GiB"},
"os_type": "rhel9",
},
{
"id": "248bbfcc-e2cd-4ccc-9782-f2a8050da612",
"name": "maximal-instance",
"status": "ACTIVE",
"metadata": assert.JSONObject{"foo": "bar"},
"tags": []string{"foobar"},
"availability_zone": "az-one",
"hypervisor": "none",
"flavor": "capacity",
"vcpu": 5,
"ram": assert.JSONObject{"value": 23, "unit": "MiB"},
"disk": assert.JSONObject{"value": 42, "unit": "GiB"},
"os_type": "image-deleted",
},
}

testSubresourceTranslation(t, "ironic-flavors", extraSetup, subresourcesInLiquidFormat, subresourcesInLegacyFormat)
}

func testSubresourceTranslation(t *testing.T, ruleID string, subresourcesInLiquidFormat, subresourcesInLegacyFormat []assert.JSONObject) {
func testSubresourceTranslation(t *testing.T, ruleID string, extraSetup func(s *test.Setup), subresourcesInLiquidFormat, subresourcesInLegacyFormat []assert.JSONObject) {
s := test.NewSetup(t,
test.WithDBFixtureFile("fixtures/start-data-small.sql"),
test.WithConfig(testSmallConfigYAML),
Expand All @@ -211,6 +357,10 @@ func testSubresourceTranslation(t *testing.T, ruleID string, subresourcesInLiqui
TranslationRuleInV1API: must.Return(core.NewTranslationRule(ruleID)),
}}

if extraSetup != nil {
extraSetup(&s)
}

_, err := s.DB.Exec(`UPDATE project_az_resources SET subresources = $1 WHERE id = 3`,
string(must.Return(json.Marshal(subresourcesInLiquidFormat))),
)
Expand Down
Loading

0 comments on commit 4ff6ee3

Please sign in to comment.