diff --git a/api/catalog/v1alpha1/openapi.yaml b/api/catalog/v1alpha1/openapi.yaml index 70e5e42..e4765c9 100644 --- a/api/catalog/v1alpha1/openapi.yaml +++ b/api/catalog/v1alpha1/openapi.yaml @@ -192,7 +192,7 @@ paths: type: string description: | Filter catalog items by service type. - Only returns items where spec.service_type matches this value. + Returns items where any resource's service_type matches. example: vm responses: @@ -301,7 +301,8 @@ paths: description: | Updates specific fields of a catalog item using JSON Merge Patch (RFC 7396). - Note that api_version and spec.service_type are immutable after creation. + Note that api_version and resource structure (resource names, + service types, requires_resources) are immutable after creation. parameters: - $ref: '#/components/parameters/CatalogItemIdPath' @@ -775,22 +776,61 @@ components: CatalogItemSpec: type: object description: | - Specification for a catalog item, defining the service type reference - and field configurations. + Specification for a catalog item. Every catalog item declares + one or more named resources (`resources`, min 1). A single-resource + offering uses `resources` with one entry. + required: + - resources properties: + resources: + type: array + minItems: 1 + description: | + Named resources. Each entry declares a service type, optional + dependency ordering, and field configurations. + items: + $ref: '#/components/schemas/CatalogResource' + + CatalogResource: + type: object + description: | + A named resource within a catalog item. + required: + - name + - service_type + properties: + name: + type: string + minLength: 1 + description: | + Unique identifier for this resource within the catalog item + (e.g., ordersDb, app). + example: ordersDb + service_type: type: string minLength: 1 description: | - The Service type this catalog item references. + The Service type for this resource. Immutable after creation. + (vm, container, database, cluster). example: vm + requires_resources: + type: array + description: | + Names of other catalog resources that must reach Ready state + before this resource is provisioned. + items: + type: string + minLength: 1 + example: + - ordersDb + fields: type: array - minItems: 1 description: | - Array of field configurations for this catalog item. + Array of field configurations for this resource. Each configuration defines constraints and defaults for fields in the service type specification. items: @@ -805,16 +845,16 @@ components: type: string minLength: 1 description: | - JSON path to the field in the ServiceType spec using dot notation. - Examples: "spec.vcpu.count", "spec.memory.size_gb", "metadata.labels.tier" - example: spec.vcpu.count + Dot-notation path to a field in the service type specification. + E.g: "vcpu.count", "memory.size_gb", "metadata.labels.tier" + example: vcpu.count display_name: type: string maxLength: 63 description: | User-facing label for this field in UI/CLI. - If omitted, derived from the path (e.g., "spec.vcpu.count" → "Vcpu Count"). + If omitted, derived from the path (e.g., "vcpu.count" → "Vcpu Count"). example: CPU Count editable: @@ -867,7 +907,9 @@ components: properties: path: type: string - description: JSON path of the field this one depends on (e.g., region). + description: | + Dot-notation path to the field this one depends on. + example: vcpu.count allowed_values: type: object additionalProperties: @@ -925,14 +967,6 @@ components: spec: $ref: '#/components/schemas/CatalogItemInstanceSpec' - resource_id: - type: string - readOnly: true - description: | - Unique identifier for the resource in the Placement Manager. - This field is output-only and set by the server during creation. - example: 650e8400-e29b-41d4-a716-446655440002 - path: type: string readOnly: true @@ -979,18 +1013,38 @@ components: items: $ref: '#/components/schemas/UserValue' + resource_ids: + type: array + readOnly: true + description: | + Unique identifier for the resource in the Placement Manager. + This field is output-only and set by the server during creation. + items: + type: string + minLength: 1 + readOnly: true + example: 650e8400-e29b-41d4-a716-446655440002 + UserValue: type: object required: + - resource - path - value properties: + resource: + type: string + minLength: 1 + description: | + Resource name this value targets. + example: ordersDb + path: type: string description: | - JSON path to the user value in the CatalogItem spec using dot notation. - Examples: "spec.vcpu.count", "spec.memory.size_gb", "metadata.labels.tier" - example: spec.vcpu.count + Dot-notation path to a field in the resource's service type + specification. Examples: "vcpu.count", "memory.size_gb" + example: vcpu.count value: description: | diff --git a/api/catalog/v1alpha1/spec.gen.go b/api/catalog/v1alpha1/spec.gen.go index 9efc9f8..221c4b0 100644 --- a/api/catalog/v1alpha1/spec.gen.go +++ b/api/catalog/v1alpha1/spec.gen.go @@ -20,101 +20,106 @@ import ( // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", - "i6a7ehvHZDtvgb5Z/F8AAAD//w==", + "7H3rcts4lv+roDhTFbuHlHW3rX9N/cttKx1Nx5fxJTvbkdcDkUcSEhJgA6Addcpf9wH2EfdJtnDhVZQl", + "O3YyPZ1vsgQCOPdzfjigPzs+i2JGgUrhDD47MeY4Aglc/3WIJQ7ZbCQhGgVnWM7VlwEIn5NYEkadgXNF", + "ya8JIBIAlWRKgKMp40jOAfnmYUQkRI7rwCccxSE4A0dEOAy9W/UlUVPEamLXoThSv/rFNR3X4fBrQjgE", + "zkDyBFxH+HOIsNmrlMDVDP/1Hnu/Nb396y37wbv+3HT7rfv0++3//2fHdeQi1utLTujMub93SwRSITH1", + "4csIRcRO80SKs028NOUXwG+JD5eL+AkUC/Mw0tMWCV1Foiiu9rKk3avZRcyoAK3DByEHHCyGn4gwKu4z", + "KoFK9RHHcUh8rOjd+SAU0Z9zYhQ7JCahMygyC90ROUckQK9uI08JK8A8eIWwWQWBWUYxwerBwGn6/d3Z", + "vD/3dmG/7+32fPCgM9/zoDXr73Xm0+7+nmKVkFgmwhl0m/uuI4nUDD0HwRLuw/IClu6Dt+fDg6P/vBn+", + "Y3RxeeHcF3n5Zw5TZ+D8aSe38R3zq9gZcs64YVdZ6pZfyDLs3nV+xME5/JqAkE9k32sCYYBeWSW4UTt/", + "haJESESZRBNAEMVyUWba7n6nG0w74HUn/Y7Xbe9PvElz2vMme0Gn1wS/1e9BiWnNnGkjeotDEiBudo0K", + "Ti3j2+jk3cHb0dHNwflPV8fDk8tn4NyPOEApo+5d5zXjExIEQJ/ItSsBHAUMhObSHN8CioFHRAjCKJIM", + "Yd8HIZCcE4G41ZMyE/dwtwfT7tTr+btdr9fBvue3pn3P34duvzUN2rv9aYmJnZyJB2b2aUZFxrqz4fnx", + "6OJidHpyczQ8GQ2PnoF3ObPuXecNFqkjfKrFFhx7xVLnWGRO+iUMtTq/Zdrrg9Hb4dHN2fnw8PTkaHQ5", + "Oj15Bra9wQLlrLp3nRFV3hOHymMBN889jYMHFCUUPsXgSwgQqJkQ8/2EcwjQ3ZyEgGLOlI4QOtNRwZpb", + "madt2NsnH/Y+ePuz1p63vwszb9b70PRmHbLX7H2Y91vNDwWe9sp2bIjR8Qa42UTRhC+H5ycHb5+Bj9lK", + "hm/IDnSdEyZfs4QGzxA4ymqYGbZ26GWe7U96/emsN/P6wV7P63cngRe0Z7te0Jz2dtsz6Oztzkp62K3R", + "QzX3VG89Y9jJ6eXN69Ork+cw2BMmkeHMveucsZD4iyOIgQZA/cUTuWWmQXCLw0SPRiLxfYAAAjRJJMIo", + "zRtQkK2F7rBhpMCSiCmBoMzMjt/tBdCferuzvX2v2ZoT70O70/U+9sL+7l5E95stVmRmu8BMu6F8sZe2", + "Z7tggZMZd8/hgzbFJ/LWBibE7TRoskB2tSGdEVqJHC3c7kz8buD1oL/r7e1PZ9682Wp7pPOh2+t/DHf3", + "9qOSCvaXuJau9JV4lvFHcYyzWxJ8mf+7ODs/Vh5OT5S5nqJnm3S6ftADrz/d3fP2m7O5R1rtjveh+7HX", + "3w2jvf0mLSlWu8Ci6rwvy6F0tcytXVGcyDnj5LcnK9Q7nWKpaVR5YB5APgddLeBQIMwht9eN0pW+3+4E", + "0A68Du61vW57D3u43+x5eDdod5vBpNnrBiWlaxXSlfJGsgIjY+3VycHV5ZvhyeXo8ODyWXKWEhPvs/mq", + "lbOuajiLgUti8hkck5tb4IIY7pZnfWd+QGyqg2oxkTHzIyIFhFO0BY1Zw0W3LRzGc9zabozpKIoSiSch", + "IDyVwJU4NDsaY1ou0+wzjlust27fq6rqL6q8uv6L+VxTYLmOnhVuJIlgefuXJAIhcRSjuznQ5fpYeWsz", + "QYC2zl8fok6ns79d2l272e57zZbX6ly2uoN2c9Bs/uK4zpTxCEtn4ARYgqdXV6UeDk5puEgLyaXNBkTE", + "IV7cmEJ0qcQVwL0pJ0CDcIHsWKTG1lb3jTE9ThlMgzyGUzAqPgGU6KK5yvCLCIchOoJbCFkcAZXo3bHj", + "OhH+9BboTBXf/Y7rRISmf7ZqSIlrq/Qs4KufETEsN7wapJv31ObFzucStnJf2WN5bAGyKKhIecxmBfpa", + "EYkY/HVGWDCDCzX83nUSEjwVpWmgS+WFprouJQKxRMaJ9BgNF0qwY0pWGRK6nAMaHSEfUyVtptfFYbhA", + "igq1YoBuCR7TXxPgi7zyRMY56kn+HyJTrTY2ugRuBqoARzOgwLEEgTC6uhodNcZ0TF+zMGR3Ah0Mz7xW", + "u535T70VRm8VtYyKqtr1e03Y6zabHqj6udsKuh7ebfW9brff7/W63Waz2Vqnho8GZNbKO4mDL/MfIRYS", + "RSww7N7Ai/QGrS/xIvfZN2yikgzHdT55GGIvC2M5cCWcwXun3uxu1J83JLh3rl0nDhOOw6rZqehG6CwJ", + "Ma/8lDvi9NsIUzwD3gj8qEHYTmnwCmzz2UJROuH3kPStQ1KGNfyuY5OXISblIJXB4Q8Fq8LD66NWYfC3", + "CV8pTc8QxnLZf49n3+PZo+JZfgDzvhQDKt7Javf1lwTAGtu2kdB+/2BI9IpY6orY6BUO3TYPkvlTK6Ll", + "W2JOPMoRk8IneRPjGdxI9hFqoual+lrbKwfJCdym+Kh6EqknG2M6jGK5QEYgiNBA1a1gi2Mi9HCtFXZ4", + "SRNg8bfbX6JffvvlH38npx+u7qZ//+tf64IiB5GE5syrcsTDOV6oqF7rTDJj1Oi4zkoe792cPGfCarUl", + "pUs35y4xdEnZ6qVzYd1umbQL47UsCqCEgOupdFEAU0JT2ZTGcJgCB+rDmKpQatyqz+iUzBKOC56prBmV", + "NK9GM/Ikyiw0OjISXyUHuw3xmDxKxb41wToV8A0JxGNiT+albSQ/C7EPOmM4tmY2pg8GIiRAosmiGB6C", + "RJtAmZxM6x7l+ttLlK913SsGWJV1nUQAv7nFYQIPmZEahcwoy6tVMq2Q95BRqTzwnZpzrSlV9a687TXm", + "9Adzcl/i217Opz3NlzXQ8FYlZSUCA/BDzEGMKaOAGEcR46DLhyAnHm39M/v8TxdFhKLWdgMdIBVgQ8jC", + "+piy6RS0+BIBAhWeMn0Qag2gki/q/GHO6yXSTsr7aaAh9udmpowChEt9Jm6WnI5p4fSH8UBv0EUPu+rH", + "CDstW5SUIkJH5tHWeulbch+Q9nkhYaroa0VImsGEVmVew2dN9EMWUMeV3FGlCyqDVVIojTNREoT6VkiO", + "CZXC1JcwxUrZ9TxmB2Nq40JRbGkd4de594dkoZtGDot7Wba/tL9o0xBWoDZlbzX4j6kFMbRiiaOJi3Ac", + "b1fdU/rrJsFWq4i4WWMOQkmKybmK7XY/ub3KOZamY4aDktG5btEREksY0wlMlY2XySPClGMqq4egsv33", + "+f6vC+JYQ0mV98V+nvpk56KoCHUK90BWs3UbuUrrJCYUuIsCLPEEC3CRHyZCAt9+fOpTsdi0xCnSUWe7", + "2TFimUL9NUqbzdBU17TKV6oCbnevuYvOOJuEEKEjfWhmbOXN5eUZOjgbCZNc6gp4v2OO5tB52rlWZzZl", + "q08P4qq7epNEmHoqsdF8hU9xiKk9TbdzIsmMJGw/h0o2LcanzyMbY3qMFynz074OL3s8sORIhuYQxiiA", + "SWKiPxFiGWDauP1rSeNIAbfc2MKzbZZ6VkzydWhgjkRAYB7g2P+oRGZc2iSZzQidVQnYsBctK8UTTrws", + "ea+jKz3BXJKd0g3zI/JZAGgrwtKfg0jTb6NpZkQJHtD9b9kGCJWddr4woRJmoA9+7XHpUqoxZ1y6aF7W", + "HZFEEeaLkm5oW26M6cWcJWGgmKkiAxFSlQHY50wU1UqkzwocVSYocXiTjr2qI6rScIz9OaFQUH29nOJj", + "A10pmzoYau7qDpzCrynMRJNIeYWlzkB36ezYLbTSuNUWTLemQc51zocXp1fnh8Ob4T/eHFxdmFnqjvtd", + "5+DH03Pz++nV5c3p65vzg5Ofhnobo+Ozt0O1Kf1z1gCld/juYPT24Me3auDR8ODo7ehELXY4HB4Nj5Rb", + "K3B7mcJNdbfiQW0DsNXnVL3qfGhNOF+qOmxOsSzaI/ODqbFyS9dph6o5VXgxOaFAzKLM6rdXIj2g2LIA", + "p6HDRTSJJiqqTBgLAVMXmZ26SEc3fXAxRRAQHZz+OsWhCjzFwmVKPkFgNlQZrCvJ0lhCiSQ43BHJbAZC", + "Fp4rGkHbdWgShmoOU47qhgNN1I3h1uPSJdNEJE7ppmcO2FeeMMQTCCs8VgX/1Wjn8O3I0MoiIiUELlK5", + "963ypZxFmlQN9NsMauzc+nHS8FlC5dhB//vf/4PGzjs/TtCh+Wopgh+eXZnflkHcJSeQ8rukOEZQFer+", + "Yw46rQIa6FJdaLRb462LIpFGuzRMa/2Q4ocNW8JQnmkC5CCHUQUdUyHNi2slXMIYrOatPjw5YtKjTJrQ", + "rfkqGcK5QNZm2sPGbFARghJKBBHji4Ygv8HNbJJ+J7FKsBpa+qIhCfCxU82vsonWZ73aneuN3ORdNjgI", + "iCnhzgp2b7hSpv1vF6cn6MKYbrHyUGqZTq0Bh0x4WwHHU4nazXbTa7WVZp1q0Ml0M6lwoAVbslIVxpI4", + "ZlyKPC4Ul/4IizvGAzGwFWhEKImSyEUR/qQ/jKlFvV2kwoceYdiix6QfQfr62OM8dawDNJcyFoMd3WLl", + "GRY1GJ/taDJ2LBnFX72cpWW5LBUT2rWpwKvMyWeqlN5qea3+trEqtXFn0OprGdo/XCdKQkniEE6nRYEW", + "M4eyR68EAq3Cm/n93C8tbf2Q0VRDMkNSKVxsCvnCKdcrYc214PYxNeVTGhY89FrXpso6TYU6QFjl6BBo", + "YE28//k6ddHpalpJCp3cP2fzpJqzdiqclt1227VznoNgofKcfkiASk+QAJAqbwJFiQYUBYTgrwSb7eoF", + "cLLeuj5n5V1NBVdNoGLjyO1ei15xy6J4H2GBft5Wniilbikaj6YF94ilcV3wa4JDYR53C+NfiWwizEEN", + "LxP2/ufrNMATgSIcvzcbuX5/jelioCY0T5qvhZvisHo2RadOQTFdmKiUjtNqpZVU2Ci0pLmP8Mo5vaas", + "okW9bDzkRh9Or+wlqoqs68ys0Gj+ZU0e5Yhik6dyW4f6NAFpPvzr9njkSM/j+juag86X9XekwXRZECa6", + "PmSsy80iJTJ/hoVnDDLGhBvj87GEGePkN6OTBgYNpUZFG2P6I5NzayY0KFqHtYRGNZrY+RbOwKEg7xj/", + "WMZ/ChFgQ5t5sA3EKpyn5hI7n0s3Be/teZL1rD6mjBIfhw8dTlWVrjx/4Q5GWQvLw56rNeRBhOwwxELk", + "AH+NATbG9JBFEaOp3Aj1wySAAVoHjjXG9CBQ8V2lTpJx5TsXFsxFfiKkytcVqWgCC0aV4wIBtYjayoaX", + "zdM5651yBKmMMaduJnV9241c7pgiFuNfVaQnOhpiniFThfRcE5PPb2K0zrmGhhqBJovS4IEK7O+OB0j5", + "YxeZrNhFQjKOZ+CimSoAbphwbeO6Gn6YcnyASKRHZRCtm96GcpG1GvXAkZXLAIG+ZOEi64cLT+qJjdQG", + "+c+UBSpvU5RyFqI4xOppNS9wsa0Iu5xrG058mXBAt5gTRWSaQhRUSaufOX7VjE5jwZLlGx6oT7Y4cAZ7", + "Gq3SLNEKTMRH4QzeKy8RY5/IhR7Va2Y3fSeMFeOaCJz7a1UP+HGidYb7cyJB79kZOJ/2+jf9ruM6Jh4O", + "2vfmyKWoUK0aP/PIpqmSTX3vlfod9UqVgvij+6Tag27vpfqkSr79qX1S9cFPz7nUFVUaW26GKv60tgeq", + "NLjyKoAX6wZQ0c0ejz++MeDUBAC9OPJQwIwFYS70CbcBCBJfogjTRBnkw80Ew7vjN80nNhOU0uP8/Noc", + "mKRHGcbGU3qRxvA1UdoxPOIMtJjVP2/zQd5dsiTtp6NRKT9eiRKfxrSMTKE0JG+CTj2ieMobmx5IQXUj", + "tVY0k0dLzGcgxdPPd29TLlbKqjJUnbcIvRReXXaCK7sTHDetKs3Gl5XjXh+8TVl6LxD70qAHy+e7R4fH", + "WUObbQFDB2ejNLjpFg6bbZPfIEB3eKHUxjikMS0Zk2nHs1AADUqH8qbQIXTKcZ7xFIBZmy6qpad5tERb", + "6oshnWNq29RUWsEEDsV2ti899Zim7PEYJ0ClvmEsyIzqyf/0J3SeZ2sqX/vhh4Jpih9+GKAjk1pLiOJQ", + "OzO144BMNeAnba7NpquIGFOEtt4dr0jqf04mwCmoaW1+72rHV8jjt822Ck1FeluHKseGILPHtJ9HmP6d", + "csJcaRFRe9KSyAFYrWYh8YEKrfM26TuIsT8H1G40HddJuIpWKb55d3fXwPpnDW/aZ8XO29Hh8ORi6LUb", + "zcZcRmHhcNJZoVZKZ1MUI8cS7l2HxUBxTJyB02k0G12r4tqZ7azoJR58dmYg6/yEjl9adWM8I1RzLyRC", + "ruyXFUUYOau8VblR39ap0zpH79owehQ4A0dF3pouV6GJyd/J9P6LQm/6ch4dh/K38xRiRfFS7FI2tHzi", + "qsFk65y0dmtjlSr2yYRTFAPXe1ixcIQ/mUCl3H1p7exEp1V7sJ3D2E31exHIriLXy9t+rWW0QphLctPi", + "0mcJhiZhibybAzdnLo1KAybKD+3TMLMESlReCFXhy3JH52qpXFfed9RuNje40b3Z1edVTfE1l6EvEl0l", + "T5Mw61NQptk1u6lbJNv1TuEFQ/qR1vpHynev1UOd9Q+VXjLT22Rnda9T0Te9TWeEtdsVqqQBMSZqvMyh", + "RiOVj6Fwt7JTuOBWVPrg5VXm6EioSlPb+atVdypeoWodqoNoAFHMJFB/UeeGzM7qGvrX+KFTWw1Xt7rK", + "Bz7GHCoWUKlKH/l+sGuTF4GQP7Jg8ZKmkr6MrPiqs/sla229/BYqylcrkRQgF5kdh4uCAT/LBh94/U65", + "/WfCggVK+0qRCeZfzzN0m/31T1ReyqIf21//WPltdOqpdnuDxUrvM9FPdTfdYvldMs/k9YybWHW3Rg/e", + "edz9TuMkQ5BQ1/4TgnGXD1yqKPsx88hGfqyOF/mQndWviKwJvd0aX19rbobUOnP7Siq+gf5k7756Pr0x", + "YlmtN+76rNxcUVgRVyYLRKRYkWL/BPKrK0TzX8O7T1M5/pvr108gn9MpDTjMF4GqoHXeUZvLXXIymwEX", + "KB1rz/IwNa92U/VYjewaY/pTAe1XeWAR2Df9yCHMDDDKVt63W9Ly83TLf1Rdz2RW510fqZbfU4G1Jpfp", + "2+aG9xzwy2rUpXKAvg5p+Y6wfBWERdSI5rwGUMF0UXOOYI6wLa5S26VQR3z1iO5bYyjfsZM12MmTIJPN", + "kZLnwkSeBQv5t4ZAviH0sTZR+I50FNL0p+QpL4kh1CQL1dcWPh4p2Agg+KLc+MmAwO8NB9hIY0rvb39h", + "8ODJmMEjoIKXUY3mN/F+f1wkwPYn+3X/6UR3pYnKcbi5OVDWmkS/7V7fEToGPgN0ppt7dC/abme/v62z", + "kRMmwdzZL/SM6Zo+q/HzFoAtXuxNEW65L0K4aPkNAtu6gZs82HZfVmhD4Uvo9CZ5RKRY5Wnm/+WFc4pv", + "Y1WmrfEb5xRmE9m/Yfj3t3Gj1LUZRLlZ8mlww6r2pNprg/Zxom9vKR+hgQFd8otVKESxh+hZUYjGmJ5O", + "dBtR4WpupS3QppzaxcUcbglLRFZ+2h7Fb4JkmCtk+j86pJWTm79uRjLUajZX7++rAB4vGcyr3bjf8YIy", + "XlAKjpviBStM+bmhA3v1cXSEiFjd5X9HwjBr9UeMwmrQodgA/ETQYXRUfw1iTI8TIW2/JDo6ufBarXYn", + "v/4fYYm2QnYH3McCkO62o0kEnPimd3C+iOdAxXbllQD11xlolmhvgNv9HsCOUmv21wU7lpauvWxldP1f", + "EuzIb7Lb/3/yR0M8Sv8/cTlfqd6C3Ch/sTVuydOtq3EfdC9r6oHlfyD5tcLiWqX/Y9W4FWWy105TKZpu", + "7B0ck528Zfr6/v8CAAD//w==", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/api/catalog/v1alpha1/types.gen.go b/api/catalog/v1alpha1/types.gen.go index 770d0c4..a505463 100644 --- a/api/catalog/v1alpha1/types.gen.go +++ b/api/catalog/v1alpha1/types.gen.go @@ -74,8 +74,9 @@ type CatalogItem struct { // Path Resource path in the format: catalog-items/{catalogItemId} Path *string `json:"path,omitempty"` - // Spec Specification for a catalog item, defining the service type reference - // and field configurations. + // Spec Specification for a catalog item. Every catalog item declares + // one or more named resources (`resources`, min 1). A single-resource + // offering uses `resources` with one entry. Spec *CatalogItemSpec `json:"spec,omitempty"` // Uid Unique identifier for the catalog item. This field is output-only and @@ -105,10 +106,6 @@ type CatalogItemInstance struct { // Path Resource path in the format: catalog-item-instances/{catalogItemInstanceId} Path *string `json:"path,omitempty"` - // ResourceId Unique identifier for the resource in the Placement Manager. - // This field is output-only and set by the server during creation. - ResourceId *string `json:"resource_id,omitempty"` - // Spec Specification for a catalog item instance, defining the catalog item reference // and field configurations. Spec CatalogItemInstanceSpec `json:"spec"` @@ -141,6 +138,10 @@ type CatalogItemInstanceSpec struct { // Immutable after creation. CatalogItemId string `json:"catalog_item_id"` + // ResourceIds Unique identifier for the resource in the Placement Manager. + // This field is output-only and set by the server during creation. + ResourceIds *[]string `json:"resource_ids,omitempty"` + // UserValues Array of user values for this catalog item instance. UserValues []UserValue `json:"user_values"` } @@ -155,17 +156,34 @@ type CatalogItemList struct { Results []CatalogItem `json:"results"` } -// CatalogItemSpec Specification for a catalog item, defining the service type reference -// and field configurations. +// CatalogItemSpec Specification for a catalog item. Every catalog item declares +// one or more named resources (`resources`, min 1). A single-resource +// offering uses `resources` with one entry. type CatalogItemSpec struct { - // Fields Array of field configurations for this catalog item. + // Resources Named resources. Each entry declares a service type, optional + // dependency ordering, and field configurations. + Resources []CatalogResource `json:"resources"` +} + +// CatalogResource A named resource within a catalog item. +type CatalogResource struct { + // Fields Array of field configurations for this resource. // Each configuration defines constraints and defaults for fields // in the service type specification. Fields *[]FieldConfiguration `json:"fields,omitempty"` - // ServiceType The Service type this catalog item references. + // Name Unique identifier for this resource within the catalog item + // (e.g., ordersDb, app). + Name string `json:"name"` + + // RequiresResources Names of other catalog resources that must reach Ready state + // before this resource is provisioned. + RequiresResources *[]string `json:"requires_resources,omitempty"` + + // ServiceType The Service type for this resource. // Immutable after creation. - ServiceType *string `json:"service_type,omitempty"` + // (vm, container, database, cluster). + ServiceType string `json:"service_type"` } // Error Error response following RFC 7807 Problem Details for HTTP APIs @@ -208,15 +226,15 @@ type FieldConfiguration struct { DependsOn *FieldConfigurationDependsOn `json:"depends_on,omitempty"` // DisplayName User-facing label for this field in UI/CLI. - // If omitted, derived from the path (e.g., "spec.vcpu.count" → "Vcpu Count"). + // If omitted, derived from the path (e.g., "vcpu.count" → "Vcpu Count"). DisplayName *string `json:"display_name,omitempty"` // Editable Whether end users can modify this field value when requesting services. // If false, the field is fixed to the default value. Editable *bool `json:"editable,omitempty"` - // Path JSON path to the field in the ServiceType spec using dot notation. - // Examples: "spec.vcpu.count", "spec.memory.size_gb", "metadata.labels.tier" + // Path Dot-notation path to a field in the service type specification. + // E.g: "vcpu.count", "memory.size_gb", "metadata.labels.tier" Path string `json:"path"` // ValidationSchema JSON Schema constraints for validating this field (draft 2020-12). @@ -238,7 +256,7 @@ type FieldConfigurationDependsOn struct { // Type is map[string][]any: keys are strings, values are arrays of any (e.g. strings or objects). AllowedValues map[string][]interface{} `json:"allowed_values"` - // Path JSON path of the field this one depends on (e.g., region). + // Path Dot-notation path to the field this one depends on. Path string `json:"path"` } @@ -302,10 +320,13 @@ type ServiceTypeList struct { // UserValue defines model for UserValue. type UserValue struct { - // Path JSON path to the user value in the CatalogItem spec using dot notation. - // Examples: "spec.vcpu.count", "spec.memory.size_gb", "metadata.labels.tier" + // Path Dot-notation path to a field in the resource's service type + // specification. Examples: "vcpu.count", "memory.size_gb" Path string `json:"path"` + // Resource Resource name this value targets. + Resource string `json:"resource"` + // Value Value for this user value. // Type depends on the field's schema (can be string, number, boolean, object, array). Value interface{} `json:"value"` @@ -388,7 +409,7 @@ type ListCatalogItemsParams struct { MaxPageSize *int32 `form:"max_page_size,omitempty" json:"max_page_size,omitempty"` // ServiceType Filter catalog items by service type. - // Only returns items where spec.service_type matches this value. + // Returns items where any resource's service_type matches. ServiceType *string `form:"service_type,omitempty" json:"service_type,omitempty"` } diff --git a/internal/catalog/handlers/v1alpha1/catalog_item.go b/internal/catalog/handlers/v1alpha1/catalog_item.go index 9e69fc2..c8b7317 100644 --- a/internal/catalog/handlers/v1alpha1/catalog_item.go +++ b/internal/catalog/handlers/v1alpha1/catalog_item.go @@ -3,7 +3,7 @@ package v1alpha1 import ( "context" - v1alpha1 "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/api/catalog/v1alpha1" "github.com/dcm-project/control-plane/internal/catalog/api/server" "github.com/dcm-project/control-plane/internal/catalog/service" ) @@ -86,17 +86,14 @@ func validateAndBuildCreateCatalogItemRequest(request server.CreateCatalogItemRe if request.Body.ApiVersion == nil || *request.Body.ApiVersion != supportedAPIVersion { return nil, ErrInvalidAPIVersion } - if request.Body.DisplayName == nil { + if request.Body.DisplayName == nil || *request.Body.DisplayName == "" { return nil, ErrInvalidDisplayName } if request.Body.Spec == nil { return nil, ErrEmptySpec } - if request.Body.Spec.ServiceType == nil { - return nil, ErrInvalidServiceType - } - if request.Body.Spec.Fields == nil { - return nil, ErrEmptyFields + if len(request.Body.Spec.Resources) == 0 { + return nil, ErrEmptyResources } return &service.CreateCatalogItemRequest{ ID: request.Params.Id, @@ -124,7 +121,6 @@ func (h *Handler) UpdateCatalogItem(ctx context.Context, request server.UpdateCa h.logger.InfoContext(ctx, "Updating catalog item", "id", request.CatalogItemId) // Body is already a CatalogItem (partial update via JSON merge patch) - // Build update request from provided fields updateReq := &service.UpdateCatalogItemRequest{ DisplayName: request.Body.DisplayName, Spec: request.Body.Spec, diff --git a/internal/catalog/handlers/v1alpha1/catalog_item_errors.go b/internal/catalog/handlers/v1alpha1/catalog_item_errors.go index af46e52..5cf90bf 100644 --- a/internal/catalog/handlers/v1alpha1/catalog_item_errors.go +++ b/internal/catalog/handlers/v1alpha1/catalog_item_errors.go @@ -16,14 +16,11 @@ var ( // ErrInvalidDisplayName indicates the display_name is invalid (empty or exceeds 63 characters) ErrInvalidDisplayName = errors.New("invalid display_name: must not be non-empty") - // ErrInvalidServiceType indicates the spec.service_type in the request body cannot be empty - ErrInvalidServiceType = errors.New("spec.service_type cannot be empty") - // ErrEmptySpec indicates the spec is empty (must have at least one field) ErrEmptySpec = errors.New("spec cannot be empty: must have at least one field") - // ErrEmptyFields indicates the spec.fields array is empty (must have at least 1 field) - ErrEmptyFields = errors.New("spec.fields cannot be empty: must have at least one field") + // ErrEmptyResources indicates the spec.resources array is empty (must have at least 1 resource) + ErrEmptyResources = errors.New("spec.resources cannot be empty: must have at least one resource") ) // mapCreateCatalogItemErrorToHTTP converts service domain errors to CreateCatalogItem HTTP responses @@ -40,8 +37,12 @@ func mapCreateCatalogItemErrorToHTTP(err error) server.CreateCatalogItemResponse }, } case errors.Is(err, service.ErrServiceTypeNotFound), + errors.Is(err, service.ErrCatalogItemSpecConflict), errors.Is(err, service.ErrDependsOnCycleDetected), - errors.Is(err, service.ErrDependsOnPathNotFound): + errors.Is(err, service.ErrDependsOnPathNotFound), + errors.Is(err, service.ErrCatalogItemResourceNameTaken), + errors.Is(err, service.ErrCatalogItemRequiresResourceNotFound), + errors.Is(err, service.ErrCatalogItemRequiresCycle): // Validation errors -> 400 Bad Request return server.CreateCatalogItem400JSONResponse(v1alpha1.Error{ Type: v1alpha1.INVALIDARGUMENT, @@ -91,9 +92,13 @@ func mapGetCatalogItemErrorToHTTP(err error) server.GetCatalogItemResponseObject // mapUpdateCatalogItemErrorToHTTP converts service domain errors to UpdateCatalogItem HTTP responses func mapUpdateCatalogItemErrorToHTTP(err error) server.UpdateCatalogItemResponseObject { switch { - case errors.Is(err, service.ErrImmutableFieldUpdate), + case errors.Is(err, service.ErrImmutableSpecStructureUpdate), + errors.Is(err, service.ErrCatalogItemSpecConflict), errors.Is(err, service.ErrDependsOnCycleDetected), - errors.Is(err, service.ErrDependsOnPathNotFound): + errors.Is(err, service.ErrDependsOnPathNotFound), + errors.Is(err, service.ErrCatalogItemResourceNameTaken), + errors.Is(err, service.ErrCatalogItemRequiresResourceNotFound), + errors.Is(err, service.ErrCatalogItemRequiresCycle): // Validation errors -> 400 Bad Request return server.UpdateCatalogItem400JSONResponse(v1alpha1.Error{ Type: v1alpha1.INVALIDARGUMENT, diff --git a/internal/catalog/handlers/v1alpha1/catalog_item_instance_errors.go b/internal/catalog/handlers/v1alpha1/catalog_item_instance_errors.go index fbb8704..5253629 100644 --- a/internal/catalog/handlers/v1alpha1/catalog_item_instance_errors.go +++ b/internal/catalog/handlers/v1alpha1/catalog_item_instance_errors.go @@ -9,16 +9,8 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/service" ) -var ( - // ErrInvalidCatalogItemId indicates the spec.catalog_item_id is missing - ErrInvalidCatalogItemId = errors.New("spec.catalog_item_id cannot be empty") - - // ErrInvalidCatalogItemInstanceAPIVersion indicates the api_version is invalid - ErrInvalidCatalogItemInstanceAPIVersion = fmt.Errorf("invalid api_version: must be set to %s", supportedAPIVersion) - - // ErrInvalidCatalogItemInstanceDisplayName indicates the display_name is invalid - ErrInvalidCatalogItemInstanceDisplayName = errors.New("invalid display_name: must not be empty") -) +// ErrInvalidCatalogItemInstanceAPIVersion indicates the api_version is invalid +var ErrInvalidCatalogItemInstanceAPIVersion = fmt.Errorf("invalid api_version: must be set to %s", supportedAPIVersion) // mapCreateCatalogItemInstanceErrorToHTTP converts service domain errors to CreateCatalogItemInstance HTTP responses func mapCreateCatalogItemInstanceErrorToHTTP(err error) server.CreateCatalogItemInstanceResponseObject { @@ -33,10 +25,18 @@ func mapCreateCatalogItemInstanceErrorToHTTP(err error) server.CreateCatalogItem }, } case errors.Is(err, service.ErrCatalogItemNotFoundForInstance), + errors.Is(err, service.ErrCatalogItemSpecConflict), errors.Is(err, service.ErrUserValuePathNotFound), errors.Is(err, service.ErrUserValueNotEditable), errors.Is(err, service.ErrUserValueValidationFailed), - errors.Is(err, service.ErrUserValueDependsOnViolation): + errors.Is(err, service.ErrUserValueDependsOnViolation), + errors.Is(err, service.ErrUserValueResourceRequired), + errors.Is(err, service.ErrUserValueResourceNotFound), + errors.Is(err, service.ErrInvalidCELExpression), + errors.Is(err, service.ErrCELResourceNotFound), + errors.Is(err, service.ErrCELSelfReference), + errors.Is(err, service.ErrCELServiceTypeOutputNotFound), + errors.Is(err, service.ErrUserValueCELNotAllowed): return server.CreateCatalogItemInstance400JSONResponse(v1alpha1.Error{ Type: v1alpha1.INVALIDARGUMENT, Status: 400, diff --git a/internal/catalog/handlers/v1alpha1/catalog_item_instance_test.go b/internal/catalog/handlers/v1alpha1/catalog_item_instance_test.go index d79db94..8d7a20b 100644 --- a/internal/catalog/handlers/v1alpha1/catalog_item_instance_test.go +++ b/internal/catalog/handlers/v1alpha1/catalog_item_instance_test.go @@ -89,7 +89,7 @@ var _ = Describe("CatalogItemInstance Handler", func() { mockSvc service.Service testTime time.Time testID string - testResourceID string + testResourceIDs = []string{"test-resource-id"} testPath string testApiVersion = "v1alpha1" testCatalogItem = "small-vm" @@ -99,7 +99,7 @@ var _ = Describe("CatalogItemInstance Handler", func() { ctx = context.Background() testTime = time.Now() testID = "test-instance-id" - testResourceID = "test-resource-id" + testResourceIDs = []string{"test-resource-id"} testPath = "catalog-item-instances/" + testID mockCIIService = &mockCatalogItemInstanceService{} mockSvc = &mockCatalogItemInstanceServiceWrapper{catalogItemInstanceService: mockCIIService} @@ -115,13 +115,13 @@ var _ = Describe("CatalogItemInstance Handler", func() { Expect(req.Spec.CatalogItemId).To(Equal(testCatalogItem)) return &v1alpha1API.CatalogItemInstance{ Uid: &testID, - ResourceId: &testResourceID, Path: &testPath, ApiVersion: testApiVersion, DisplayName: "My Instance", Spec: v1alpha1API.CatalogItemInstanceSpec{ CatalogItemId: testCatalogItem, UserValues: []v1alpha1API.UserValue{{Path: "spec.vcpu.count", Value: 4}}, + ResourceIds: &testResourceIDs, }, CreateTime: &testTime, UpdateTime: &testTime, @@ -156,13 +156,13 @@ var _ = Describe("CatalogItemInstance Handler", func() { path := "catalog-item-instances/" + userID return &v1alpha1API.CatalogItemInstance{ Uid: &userID, - ResourceId: &testResourceID, Path: &path, ApiVersion: testApiVersion, DisplayName: "Custom ID", Spec: v1alpha1API.CatalogItemInstanceSpec{ CatalogItemId: testCatalogItem, UserValues: []v1alpha1API.UserValue{}, + ResourceIds: &testResourceIDs, }, CreateTime: &testTime, UpdateTime: &testTime, @@ -314,13 +314,13 @@ var _ = Describe("CatalogItemInstance Handler", func() { CatalogItemInstances: []v1alpha1API.CatalogItemInstance{ { Uid: &testID, - ResourceId: &testResourceID, Path: &testPath, ApiVersion: testApiVersion, DisplayName: "Instance 1", Spec: v1alpha1API.CatalogItemInstanceSpec{ CatalogItemId: testCatalogItem, UserValues: []v1alpha1API.UserValue{}, + ResourceIds: &testResourceIDs, }, }, }, @@ -392,13 +392,13 @@ var _ = Describe("CatalogItemInstance Handler", func() { Expect(id).To(Equal(testID)) return &v1alpha1API.CatalogItemInstance{ Uid: &testID, - ResourceId: &testResourceID, Path: &testPath, ApiVersion: testApiVersion, DisplayName: "Test Instance", Spec: v1alpha1API.CatalogItemInstanceSpec{ CatalogItemId: testCatalogItem, UserValues: []v1alpha1API.UserValue{}, + ResourceIds: &testResourceIDs, }, CreateTime: &testTime, UpdateTime: &testTime, @@ -462,18 +462,18 @@ var _ = Describe("CatalogItemInstance Handler", func() { Describe("RehydrateCatalogItemInstance", func() { Context("with valid request", func() { It("should rehydrate instance and return 200", func() { - newResourceID := "new-resource-id" + newResourceIDs := []string{"new-resource-id"} mockCIIService.rehydrateFunc = func(_ context.Context, id string) (*v1alpha1API.CatalogItemInstance, error) { Expect(id).To(Equal(testID)) return &v1alpha1API.CatalogItemInstance{ Uid: &testID, - ResourceId: &newResourceID, Path: &testPath, ApiVersion: testApiVersion, DisplayName: "Rehydrated Instance", Spec: v1alpha1API.CatalogItemInstanceSpec{ CatalogItemId: testCatalogItem, UserValues: []v1alpha1API.UserValue{}, + ResourceIds: &newResourceIDs, }, CreateTime: &testTime, UpdateTime: &testTime, @@ -490,7 +490,7 @@ var _ = Describe("CatalogItemInstance Handler", func() { rehydrated := response.(server.RehydrateCatalogItemInstance200JSONResponse) Expect(*rehydrated.Uid).To(Equal(testID)) - Expect(*rehydrated.ResourceId).To(Equal(newResourceID)) + Expect(*rehydrated.Spec.ResourceIds).To(Equal(newResourceIDs)) }) }) diff --git a/internal/catalog/handlers/v1alpha1/catalog_item_test.go b/internal/catalog/handlers/v1alpha1/catalog_item_test.go index ce31a8c..2192799 100644 --- a/internal/catalog/handlers/v1alpha1/catalog_item_test.go +++ b/internal/catalog/handlers/v1alpha1/catalog_item_test.go @@ -11,10 +11,23 @@ import ( v1alpha1API "github.com/dcm-project/control-plane/api/catalog/v1alpha1" "github.com/dcm-project/control-plane/internal/catalog/api/server" - v1alpha1 "github.com/dcm-project/control-plane/internal/catalog/handlers/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/handlers/v1alpha1" "github.com/dcm-project/control-plane/internal/catalog/service" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) +func strPtr(s string) *string { + return &s +} + +func catalogItemBody(apiVersion, displayName string, spec v1alpha1API.CatalogItemSpec) *v1alpha1API.CatalogItem { + return &v1alpha1API.CatalogItem{ + ApiVersion: &apiVersion, + DisplayName: &displayName, + Spec: &spec, + } +} + // Mock CatalogItemService for testing type mockCatalogItemService struct { listFunc func(ctx context.Context, opts service.CatalogItemListOptions) (*service.CatalogItemListResult, error) @@ -82,17 +95,15 @@ func (m *mockCatalogItemServiceWrapper) Seed(_ context.Context) error { var _ = Describe("CatalogItem Handler", func() { var ( - ctx context.Context - handler *v1alpha1.Handler - mockCIService *mockCatalogItemService - mockSvc service.Service - testTime time.Time - testID string - testPath string - testApiVersion = "v1alpha1" - serviceTypeVM = "vm" - serviceTypeContainer = "container" - strintPtr = func(s string) *string { return &s } + ctx context.Context + handler *v1alpha1.Handler + mockCIService *mockCatalogItemService + mockSvc service.Service + testTime time.Time + testID string + testPath string + testApiVersion = "v1alpha1" + serviceTypeVM = "vm" ) BeforeEach(func() { @@ -109,37 +120,26 @@ var _ = Describe("CatalogItem Handler", func() { Context("with valid request", func() { It("should create a catalog item and return 201", func() { displayName := "Test Catalog Item" + spec := testutil.CatalogSpec("vm", []v1alpha1API.FieldConfiguration{ + {Path: "spec.vcpu.count", Default: 2}, + }) mockCIService.createFunc = func(_ context.Context, req *service.CreateCatalogItemRequest) (*v1alpha1API.CatalogItem, error) { Expect(req.DisplayName).To(Equal(displayName)) Expect(req.ApiVersion).To(Equal("v1alpha1")) - Expect(*req.Spec.ServiceType).To(Equal(serviceTypeVM)) + Expect(req.Spec.Resources[0].ServiceType).To(Equal(serviceTypeVM)) return &v1alpha1API.CatalogItem{ Uid: &testID, Path: &testPath, ApiVersion: &testApiVersion, - DisplayName: strintPtr(displayName), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{ - {Path: "spec.vcpu.count", Default: 2}, - }, - }, - CreateTime: &testTime, - UpdateTime: &testTime, + DisplayName: &displayName, + Spec: &spec, + CreateTime: &testTime, + UpdateTime: &testTime, }, nil } request := server.CreateCatalogItemRequestObject{ - Body: &v1alpha1API.CatalogItem{ - ApiVersion: &testApiVersion, - DisplayName: strintPtr(displayName), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{ - {Path: "spec.vcpu.count", Default: 2}, - }, - }, - }, + Body: catalogItemBody(testApiVersion, displayName, spec), } response, err := handler.CreateCatalogItem(ctx, request) @@ -154,6 +154,7 @@ var _ = Describe("CatalogItem Handler", func() { It("should handle optional ID query param", func() { userID := "my-catalog-item" displayName := "My Item" + spec := testutil.CatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}) mockCIService.createFunc = func(_ context.Context, req *service.CreateCatalogItemRequest) (*v1alpha1API.CatalogItem, error) { Expect(req.ID).ToNot(BeNil()) Expect(*req.ID).To(Equal(userID)) @@ -162,26 +163,16 @@ var _ = Describe("CatalogItem Handler", func() { Uid: &userID, Path: &path, ApiVersion: &testApiVersion, - DisplayName: strintPtr(displayName), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, - CreateTime: &testTime, - UpdateTime: &testTime, + DisplayName: &displayName, + Spec: &spec, + CreateTime: &testTime, + UpdateTime: &testTime, }, nil } request := server.CreateCatalogItemRequestObject{ Params: v1alpha1API.CreateCatalogItemParams{Id: &userID}, - Body: &v1alpha1API.CatalogItem{ - ApiVersion: &testApiVersion, - DisplayName: strintPtr(displayName), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, - }, + Body: catalogItemBody(testApiVersion, displayName, spec), } response, err := handler.CreateCatalogItem(ctx, request) @@ -195,12 +186,8 @@ var _ = Describe("CatalogItem Handler", func() { It("should return 400 when api_version is nil", func() { request := server.CreateCatalogItemRequestObject{ Body: &v1alpha1API.CatalogItem{ - ApiVersion: nil, - DisplayName: strintPtr("My Item"), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + DisplayName: strPtr("My Item"), + Spec: testutil.PtrCatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }, } @@ -216,11 +203,9 @@ var _ = Describe("CatalogItem Handler", func() { }) It("should return 400 when api_version is not v1alpha1", func() { + spec := testutil.CatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}) request := server.CreateCatalogItemRequestObject{ - Body: &v1alpha1API.CatalogItem{ - ApiVersion: strintPtr("v1beta1"), - DisplayName: strintPtr("My Item"), - }, + Body: catalogItemBody("v1beta1", "My Item", spec), } response, err := handler.CreateCatalogItem(ctx, request) @@ -235,14 +220,11 @@ var _ = Describe("CatalogItem Handler", func() { }) It("should return 400 when display_name is nil", func() { + spec := testutil.CatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}) request := server.CreateCatalogItemRequestObject{ Body: &v1alpha1API.CatalogItem{ - ApiVersion: &testApiVersion, - DisplayName: nil, - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + ApiVersion: &testApiVersion, + Spec: &spec, }, } @@ -261,8 +243,7 @@ var _ = Describe("CatalogItem Handler", func() { request := server.CreateCatalogItemRequestObject{ Body: &v1alpha1API.CatalogItem{ ApiVersion: &testApiVersion, - DisplayName: strintPtr("My Item"), - Spec: nil, + DisplayName: strPtr("My Item"), }, } @@ -277,15 +258,12 @@ var _ = Describe("CatalogItem Handler", func() { Expect(*badRequest.Detail).To(ContainSubstring("spec")) }) - It("should return 400 when spec.service_type is nil", func() { + It("should return 400 when spec.resources is empty", func() { request := server.CreateCatalogItemRequestObject{ Body: &v1alpha1API.CatalogItem{ ApiVersion: &testApiVersion, - DisplayName: strintPtr("My Item"), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: nil, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + DisplayName: strPtr("My Item"), + Spec: &v1alpha1API.CatalogItemSpec{Resources: []v1alpha1API.CatalogResource{}}, }, } @@ -297,30 +275,7 @@ var _ = Describe("CatalogItem Handler", func() { Expect(badRequest.Status).To(Equal(int32(400))) Expect(badRequest.Type).To(Equal(v1alpha1API.INVALIDARGUMENT)) Expect(badRequest.Detail).ToNot(BeNil()) - Expect(*badRequest.Detail).To(ContainSubstring("spec.service_type")) - }) - - It("should return 400 when spec.fields is nil", func() { - request := server.CreateCatalogItemRequestObject{ - Body: &v1alpha1API.CatalogItem{ - ApiVersion: &testApiVersion, - DisplayName: strintPtr("My Item"), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: nil, - }, - }, - } - - response, err := handler.CreateCatalogItem(ctx, request) - Expect(err).ToNot(HaveOccurred()) - Expect(response).To(BeAssignableToTypeOf(server.CreateCatalogItem400JSONResponse{})) - - badRequest := response.(server.CreateCatalogItem400JSONResponse) - Expect(badRequest.Status).To(Equal(int32(400))) - Expect(badRequest.Type).To(Equal(v1alpha1API.INVALIDARGUMENT)) - Expect(badRequest.Detail).ToNot(BeNil()) - Expect(*badRequest.Detail).To(ContainSubstring("fields")) + Expect(*badRequest.Detail).To(ContainSubstring("resources")) }) }) @@ -330,15 +285,9 @@ var _ = Describe("CatalogItem Handler", func() { return nil, service.ErrCatalogItemIDTaken } + spec := testutil.CatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}) request := server.CreateCatalogItemRequestObject{ - Body: &v1alpha1API.CatalogItem{ - ApiVersion: &testApiVersion, - DisplayName: strintPtr("Duplicate"), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, - }, + Body: catalogItemBody(testApiVersion, "Duplicate", spec), } response, err := handler.CreateCatalogItem(ctx, request) @@ -357,15 +306,9 @@ var _ = Describe("CatalogItem Handler", func() { return nil, service.ErrServiceTypeNotFound } + spec := testutil.CatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}) request := server.CreateCatalogItemRequestObject{ - Body: &v1alpha1API.CatalogItem{ - ApiVersion: &testApiVersion, - DisplayName: strintPtr("Test"), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, - }, + Body: catalogItemBody(testApiVersion, "Test", spec), } response, err := handler.CreateCatalogItem(ctx, request) @@ -386,15 +329,9 @@ var _ = Describe("CatalogItem Handler", func() { return nil, errors.New("database error") } + spec := testutil.CatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}) request := server.CreateCatalogItemRequestObject{ - Body: &v1alpha1API.CatalogItem{ - ApiVersion: &testApiVersion, - DisplayName: strintPtr("Test"), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, - }, + Body: catalogItemBody(testApiVersion, "Test", spec), } response, err := handler.CreateCatalogItem(ctx, request) @@ -411,6 +348,8 @@ var _ = Describe("CatalogItem Handler", func() { Describe("ListCatalogItems", func() { Context("with valid request", func() { It("should list catalog items and return 200", func() { + spec := testutil.CatalogSpecVM([]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}) + displayName := "Item 1" mockCIService.listFunc = func(_ context.Context, _ service.CatalogItemListOptions) (*service.CatalogItemListResult, error) { return &service.CatalogItemListResult{ CatalogItems: []v1alpha1API.CatalogItem{ @@ -418,8 +357,8 @@ var _ = Describe("CatalogItem Handler", func() { Uid: &testID, Path: &testPath, ApiVersion: &testApiVersion, - DisplayName: strintPtr("Item 1"), - Spec: &v1alpha1API.CatalogItemSpec{ServiceType: &serviceTypeVM}, + DisplayName: &displayName, + Spec: &spec, }, }, }, nil @@ -502,14 +441,16 @@ var _ = Describe("CatalogItem Handler", func() { Describe("GetCatalogItem", func() { Context("with valid request", func() { It("should get a catalog item and return 200", func() { + spec := testutil.CatalogSpecVM([]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}) + displayName := "Test Item" mockCIService.getFunc = func(_ context.Context, id string) (*v1alpha1API.CatalogItem, error) { Expect(id).To(Equal(testID)) return &v1alpha1API.CatalogItem{ Uid: &testID, Path: &testPath, ApiVersion: &testApiVersion, - DisplayName: strintPtr("Test Item"), - Spec: &v1alpha1API.CatalogItemSpec{ServiceType: &serviceTypeVM}, + DisplayName: &displayName, + Spec: &spec, CreateTime: &testTime, UpdateTime: &testTime, }, nil @@ -573,6 +514,7 @@ var _ = Describe("CatalogItem Handler", func() { Context("with valid update", func() { It("should update catalog item and return 200", func() { displayName := "Updated Name" + spec := testutil.CatalogSpecVM([]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}) mockCIService.updateFunc = func(_ context.Context, id string, req *service.UpdateCatalogItemRequest) (*v1alpha1API.CatalogItem, error) { Expect(id).To(Equal(testID)) Expect(req.DisplayName).ToNot(BeNil()) @@ -581,8 +523,8 @@ var _ = Describe("CatalogItem Handler", func() { Uid: &testID, Path: &testPath, ApiVersion: &testApiVersion, - DisplayName: strintPtr(displayName), - Spec: &v1alpha1API.CatalogItemSpec{ServiceType: &serviceTypeVM}, + DisplayName: &displayName, + Spec: &spec, UpdateTime: &testTime, }, nil } @@ -590,7 +532,7 @@ var _ = Describe("CatalogItem Handler", func() { request := server.UpdateCatalogItemRequestObject{ CatalogItemId: testID, Body: &v1alpha1API.CatalogItem{ - DisplayName: strintPtr(displayName), + DisplayName: &displayName, }, } @@ -604,20 +546,21 @@ var _ = Describe("CatalogItem Handler", func() { It("should update display_name only", func() { displayName := "New Name" + spec := testutil.CatalogSpecVM([]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}) mockCIService.updateFunc = func(_ context.Context, _ string, req *service.UpdateCatalogItemRequest) (*v1alpha1API.CatalogItem, error) { Expect(req.DisplayName).ToNot(BeNil()) Expect(req.Spec).To(BeNil()) return &v1alpha1API.CatalogItem{ Uid: &testID, - DisplayName: strintPtr(displayName), - Spec: &v1alpha1API.CatalogItemSpec{ServiceType: &serviceTypeVM}, + DisplayName: &displayName, + Spec: &spec, }, nil } request := server.UpdateCatalogItemRequestObject{ CatalogItemId: testID, Body: &v1alpha1API.CatalogItem{ - DisplayName: strintPtr(displayName), + DisplayName: &displayName, }, } @@ -630,16 +573,14 @@ var _ = Describe("CatalogItem Handler", func() { Context("with immutable field update attempt", func() { It("should return 400 for immutable field", func() { mockCIService.updateFunc = func(_ context.Context, _ string, _ *service.UpdateCatalogItemRequest) (*v1alpha1API.CatalogItem, error) { - return nil, service.ErrImmutableFieldUpdate + return nil, service.ErrImmutableSpecStructureUpdate } + spec := testutil.CatalogSpec("container", nil) request := server.UpdateCatalogItemRequestObject{ CatalogItemId: testID, Body: &v1alpha1API.CatalogItem{ - ApiVersion: strintPtr("v2beta1"), // Attempting to change immutable field - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeContainer, // Attempting to change immutable field - }, + Spec: &spec, }, } @@ -659,10 +600,11 @@ var _ = Describe("CatalogItem Handler", func() { return nil, service.ErrCatalogItemNotFound } + updatedName := "Updated" request := server.UpdateCatalogItemRequestObject{ CatalogItemId: "nonexistent", Body: &v1alpha1API.CatalogItem{ - DisplayName: strintPtr("Updated"), + DisplayName: &updatedName, }, } @@ -682,10 +624,11 @@ var _ = Describe("CatalogItem Handler", func() { return nil, errors.New("database error") } + updatedName := "Updated" request := server.UpdateCatalogItemRequestObject{ CatalogItemId: testID, Body: &v1alpha1API.CatalogItem{ - DisplayName: strintPtr("Updated"), + DisplayName: &updatedName, }, } diff --git a/internal/catalog/handlers/v1alpha1/handler.go b/internal/catalog/handlers/v1alpha1/handler.go index 7a79eba..13eb12c 100644 --- a/internal/catalog/handlers/v1alpha1/handler.go +++ b/internal/catalog/handlers/v1alpha1/handler.go @@ -42,7 +42,7 @@ var clientErrors = []error{ service.ErrCatalogItemNotFound, service.ErrCatalogItemIDTaken, service.ErrCatalogItemHasInstances, - service.ErrImmutableFieldUpdate, + service.ErrImmutableSpecStructureUpdate, service.ErrCatalogItemInstanceNotFound, service.ErrCatalogItemInstanceIDTaken, service.ErrCatalogItemNotFoundForInstance, @@ -51,7 +51,18 @@ var clientErrors = []error{ service.ErrUserValueValidationFailed, service.ErrDependsOnCycleDetected, service.ErrDependsOnPathNotFound, + service.ErrCatalogItemSpecConflict, + service.ErrCatalogItemResourceNameTaken, + service.ErrCatalogItemRequiresResourceNotFound, + service.ErrCatalogItemRequiresCycle, + service.ErrUserValueResourceRequired, + service.ErrUserValueResourceNotFound, service.ErrUserValueDependsOnViolation, + service.ErrInvalidCELExpression, + service.ErrCELResourceNotFound, + service.ErrCELSelfReference, + service.ErrCELServiceTypeOutputNotFound, + service.ErrUserValueCELNotAllowed, service.ErrPlacementManagerPolicyRejected, service.ErrPlacementManagerProviderError, service.ErrPlacementManagerPolicyDependency, diff --git a/internal/catalog/service/catalog_item.go b/internal/catalog/service/catalog_item.go index b151d82..a394b53 100644 --- a/internal/catalog/service/catalog_item.go +++ b/internal/catalog/service/catalog_item.go @@ -15,7 +15,7 @@ type CreateCatalogItemRequest struct { ID *string // Optional user-specified ID ApiVersion string // e.g., "v1alpha1" DisplayName string // Required, max 63 chars - Spec v1alpha1.CatalogItemSpec // Required, contains service_type and fields + Spec v1alpha1.CatalogItemSpec // Required, contains catalog resources } // UpdateCatalogItemRequest contains the parameters for updating a catalog item @@ -95,9 +95,8 @@ func (s *catalogItemService) Create(ctx context.Context, req *CreateCatalogItemR // Convert to store model storeModel := catalogItemToStoreModel(id, path, req) - // Validate: no cyclic depends_on references among fields - if err := validateFieldDependsOnCycles(storeModel.Spec.Fields); err != nil { - s.logger.WarnContext(ctx, "Catalog item field depends_on validation failed", "id", id, "error", err) + if err := validateCatalogItemSpec(ctx, s.store, storeModel.Spec); err != nil { + s.logger.WarnContext(ctx, "Catalog item spec validation failed", "id", id, "error", err) return nil, err } @@ -142,9 +141,9 @@ func (s *catalogItemService) Update(ctx context.Context, id string, req *UpdateC return nil, err } - // Validate: no cyclic depends_on references among fields - if err := validateFieldDependsOnCycles(updated.Spec.Fields); err != nil { - s.logger.WarnContext(ctx, "Catalog item field depends_on validation failed on update", "id", id, "error", err) + // Validate spec after merge + if err := validateCatalogItemSpec(ctx, s.store, updated.Spec); err != nil { + s.logger.WarnContext(ctx, "Catalog item spec validation failed on update", "id", id, "error", err) return nil, err } @@ -176,83 +175,15 @@ func mergeCatalogItem(existing *model.CatalogItem, req *UpdateCatalogItemRequest // Validate and apply spec if provided if req.Spec != nil { - // Check immutability: spec.service_type cannot be changed - if req.Spec.ServiceType != nil && *req.Spec.ServiceType != existing.Spec.ServiceType { - return nil, ErrImmutableFieldUpdate - } - - var fields []model.FieldConfiguration - if req.Spec.Fields != nil { - // Convert API spec to model spec - fields = FieldConfigurationsToModel(*req.Spec.Fields) - } - merged.Spec = model.CatalogItemSpec{ - ServiceType: existing.Spec.ServiceType, - Fields: fields, + newSpec := catalogItemSpecAPIToModel(*req.Spec) + if err := validateCatalogImmutable(existing.Spec, newSpec); err != nil { + return nil, err } + merged.Spec = newSpec } return &merged, nil } -// validateFieldDependsOnCycles checks that every depends_on path references an existing -// field and that there are no cyclic depends_on references. It builds a directed graph -// (field path → depends_on path) and performs DFS-based cycle detection. -func validateFieldDependsOnCycles(fields []model.FieldConfiguration) error { - knownPaths := make(map[string]bool) - for _, f := range fields { - knownPaths[f.Path] = true - } - - // Build adjacency: field path → source path it depends on - edges := make(map[string]string) - for _, f := range fields { - if f.DependsOn != nil { - depPath := f.DependsOn.Path - if !knownPaths[depPath] { - return fmt.Errorf("%w: field %s depends_on path %q not found in fields", ErrDependsOnPathNotFound, f.Path, depPath) - } - edges[f.Path] = depPath - } - } - - if len(edges) == 0 { - return nil - } - - // DFS cycle detection - const ( - unvisited = 0 - visiting = 1 - visited = 2 - ) - state := make(map[string]int) - - var visit func(path string) error - visit = func(path string) error { - if state[path] == visited { - return nil - } - if state[path] == visiting { - return fmt.Errorf("%w: cycle involving %s", ErrDependsOnCycleDetected, path) - } - state[path] = visiting - if dep, ok := edges[path]; ok { - if err := visit(dep); err != nil { - return err - } - } - state[path] = visited - return nil - } - - for path := range edges { - if err := visit(path); err != nil { - return err - } - } - return nil -} - // Delete deletes a catalog item by ID func (s *catalogItemService) Delete(ctx context.Context, id string) error { err := s.store.CatalogItem().Delete(ctx, id) diff --git a/internal/catalog/service/catalog_item_converter.go b/internal/catalog/service/catalog_item_converter.go index 6c62008..a44d9d9 100644 --- a/internal/catalog/service/catalog_item_converter.go +++ b/internal/catalog/service/catalog_item_converter.go @@ -10,41 +10,29 @@ import ( // catalogItemToStoreModel converts a CreateCatalogItemRequest to a store model func catalogItemToStoreModel(id, path string, req *CreateCatalogItemRequest) model.CatalogItem { - fields := FieldConfigurationsToModel(*req.Spec.Fields) + spec := catalogItemSpecAPIToModel(req.Spec) - storeModel := model.CatalogItem{ + return model.CatalogItem{ ID: id, ApiVersion: req.ApiVersion, DisplayName: req.DisplayName, - Spec: model.CatalogItemSpec{ - ServiceType: *req.Spec.ServiceType, - Fields: fields, - }, - Path: path, - SpecServiceType: *req.Spec.ServiceType, // Indexed field for filtering + Spec: spec, + Path: path, } - - return storeModel } // catalogItemToAPIType converts a store model to an API type func catalogItemToAPIType(m *model.CatalogItem) v1alpha1.CatalogItem { - fields := FieldConfigurationsFromModel(m.Spec.Fields) - - apiType := v1alpha1.CatalogItem{ + spec := catalogItemSpecModelToAPI(m.Spec) + return v1alpha1.CatalogItem{ ApiVersion: &m.ApiVersion, DisplayName: &m.DisplayName, - Spec: &v1alpha1.CatalogItemSpec{ - ServiceType: &m.Spec.ServiceType, - Fields: &fields, - }, - Path: &m.Path, - Uid: &m.ID, - CreateTime: &m.CreateTime, - UpdateTime: &m.UpdateTime, + Spec: &spec, + Path: &m.Path, + Uid: &m.ID, + CreateTime: &m.CreateTime, + UpdateTime: &m.UpdateTime, } - - return apiType } // mapCatalogItemStoreError converts store errors to service domain errors diff --git a/internal/catalog/service/catalog_item_instance.go b/internal/catalog/service/catalog_item_instance.go index a3bcf4c..0c4d6ba 100644 --- a/internal/catalog/service/catalog_item_instance.go +++ b/internal/catalog/service/catalog_item_instance.go @@ -100,35 +100,61 @@ func (s *catalogItemInstanceService) List(ctx context.Context, opts CatalogItemI func (s *catalogItemInstanceService) Create(ctx context.Context, req *CreateCatalogItemInstanceRequest) (*v1alpha1.CatalogItemInstance, error) { // Generate IDs id := getOrGenerateID(req.ID) - resourceID := uuid.New().String() - // Generate path path := fmt.Sprintf("catalog-item-instances/%s", id) - // Build resource spec (resolves reference chain and validates user_values) - resourceSpec, err := s.specBuilder.BuildResourceSpec(ctx, req.Spec.CatalogItemId, req.Spec.UserValues) + catalogItem, err := s.store.CatalogItem().Get(ctx, req.Spec.CatalogItemId) if err != nil { - s.logger.WarnContext(ctx, "Failed to build resource spec", + if errors.Is(err, store.ErrCatalogItemNotFound) { + return nil, ErrCatalogItemNotFoundForInstance + } + return nil, err + } + + if err := validateUserValuesForCatalogItem(catalogItem.Spec, req.Spec.UserValues); err != nil { + return nil, err + } + + return s.createInstance(ctx, id, path, req) +} + +func (s *catalogItemInstanceService) createInstance(ctx context.Context, id, path string, req *CreateCatalogItemInstanceRequest) (*v1alpha1.CatalogItemInstance, error) { + resolved, err := s.specBuilder.BuildResourceGraph(ctx, req.Spec.CatalogItemId, req.Spec.UserValues) + if err != nil { + s.logger.WarnContext(ctx, "Failed to build resource graph", "id", id, "catalog_item_id", req.Spec.CatalogItemId, "error", err, ) return nil, err } + if len(resolved) == 0 { + return nil, fmt.Errorf("%w: catalog item has no resources", ErrCatalogItemSpecConflict) + } - // DB first — fail fast on constraint violations (ID conflict, FK violation) - storeModel := catalogItemInstanceToStoreModel(id, resourceID, path, req) + resourceIDs := make([]string, len(resolved)) + for i, res := range resolved { + resourceIDs[i] = res.ResourceId + } + + storeModel := catalogItemInstanceToStoreModel(id, path, req, resourceIDs) createdModel, err := s.store.CatalogItemInstance().Create(ctx, storeModel) if err != nil { s.logger.ErrorContext(ctx, "Failed to create catalog item instance in store", "id", id, "error", err) return nil, mapCatalogItemInstanceStoreError(err) } - // Call Placement Manager — only after DB validation passes - s.logger.DebugContext(ctx, "Calling placement manager to create resource", "id", id) + // TODO: Placement for multi-resources + // Call Placement Manager with the first resolved resource + // until multi-resource placement is wired. + res := resolved[0] + s.logger.DebugContext(ctx, "Calling placement manager to create resource", + "id", id, + "resource_name", res.Name, + ) _, err = s.pmClient.CreateResource(ctx, placement.CreateResourceRequest{ CatalogItemInstanceID: id, - Spec: resourceSpec, - }, resourceID) + Spec: res.Spec, + }, res.ResourceId) if err != nil { mapped := mapPlacementError(err, ErrPlacementManagerCreateFailed) if rbErr := s.rollbackCatalogItemInstanceCreate(id); rbErr != nil { @@ -146,8 +172,11 @@ func (s *catalogItemInstanceService) Create(ctx context.Context, req *CreateCata return nil, mapped } - s.logger.InfoContext(ctx, "Catalog item instance created", "id", id, "catalog_item_id", req.Spec.CatalogItemId) - // Convert result back to API type + s.logger.InfoContext(ctx, "Catalog item instance created", + "id", id, + "catalog_item_id", req.Spec.CatalogItemId, + "resource_count", len(resolved), + ) apiType := catalogItemInstanceToAPIType(createdModel) return &apiType, nil } @@ -176,8 +205,13 @@ func (s *catalogItemInstanceService) Rehydrate(ctx context.Context, id string) ( return nil, mapCatalogItemInstanceStoreError(err) } - oldResourceID := instance.ResourceID - // Generate new resource ID + _, err = s.store.CatalogItem().Get(ctx, instance.Spec.CatalogItemId) + if err != nil { + return nil, mapCatalogItemStoreError(err) + } + + // TODO: Rehydrate for multi-resources + oldResourceID := instance.Spec.ResourceIDs[0] newResourceID := uuid.New().String() // DB first — CAS rejects concurrent callers here @@ -225,22 +259,24 @@ func (s *catalogItemInstanceService) Rehydrate(ctx context.Context, id string) ( // Delete deletes a catalog item instance by ID func (s *catalogItemInstanceService) Delete(ctx context.Context, id string) error { - // Fetch instance for 404 handling and to get the resource ID instance, err := s.store.CatalogItemInstance().Get(ctx, id) if err != nil { return mapCatalogItemInstanceStoreError(err) } - // Delete PM resource using the stored resource ID - if instance.ResourceID != "" { - s.logger.DebugContext(ctx, "Calling placement manager to delete resource", "id", id, "resource_id", instance.ResourceID) - if err := s.pmClient.DeleteResource(ctx, instance.ResourceID); err != nil { - s.logger.ErrorContext(ctx, "Placement manager delete failed", "id", id, "error", err) - return fmt.Errorf("%w: %s", ErrPlacementManagerDeleteFailed, err.Error()) - } + _, err = s.store.CatalogItem().Get(ctx, instance.Spec.CatalogItemId) + if err != nil { + return mapCatalogItemStoreError(err) + } + // TODO: Placement deletion for multi-resources + // Call Placement Manager with the first resource + // until multi-resource placement deletion is wired. + s.logger.DebugContext(ctx, "Calling placement manager to delete resource", "id", id, "resource_id", instance.Spec.ResourceIDs[0]) + if err := s.pmClient.DeleteResource(ctx, instance.Spec.ResourceIDs[0]); err != nil { + s.logger.ErrorContext(ctx, "Placement manager delete failed", "id", id, "error", err) + return fmt.Errorf("%w: %s", ErrPlacementManagerDeleteFailed, err.Error()) } - // Delete local record err = s.store.CatalogItemInstance().Delete(ctx, id) if err != nil { s.logger.ErrorContext(ctx, "Failed to delete catalog item instance from store", "id", id, "error", err) @@ -251,6 +287,8 @@ func (s *catalogItemInstanceService) Delete(ctx context.Context, id string) erro return nil } +// rollbackCatalogItemInstanceCreate deletes a catalog item instance after a failed +// placement create. Used with the DB-first create path so PM failures do not leave orphans. func (s *catalogItemInstanceService) rollbackCatalogItemInstanceCreate(id string) error { rbCtx, cancel := context.WithTimeout(context.Background(), catalogItemInstanceRollbackTimeout) defer cancel() diff --git a/internal/catalog/service/catalog_item_instance_converter.go b/internal/catalog/service/catalog_item_instance_converter.go index 04084a5..e1616c3 100644 --- a/internal/catalog/service/catalog_item_instance_converter.go +++ b/internal/catalog/service/catalog_item_instance_converter.go @@ -9,54 +9,69 @@ import ( ) // catalogItemInstanceToStoreModel converts a CreateCatalogItemInstanceRequest to a store model -func catalogItemInstanceToStoreModel(id, resourceID, path string, req *CreateCatalogItemInstanceRequest) model.CatalogItemInstance { +func catalogItemInstanceToStoreModel(id, path string, req *CreateCatalogItemInstanceRequest, resourceIDs []string) model.CatalogItemInstance { userValues := make([]model.UserValue, len(req.Spec.UserValues)) for i, uv := range req.Spec.UserValues { - userValues[i] = model.UserValue{ - Path: uv.Path, - Value: uv.Value, - } + userValues[i] = userValueAPIToModel(uv) + } + + spec := model.CatalogItemInstanceSpec{ + CatalogItemId: req.Spec.CatalogItemId, + UserValues: userValues, + ResourceIDs: append([]string(nil), resourceIDs...), } return model.CatalogItemInstance{ - ID: id, - ApiVersion: req.ApiVersion, - DisplayName: req.DisplayName, - Spec: model.CatalogItemInstanceSpec{ - CatalogItemId: req.Spec.CatalogItemId, - UserValues: userValues, - }, - ResourceID: resourceID, + ID: id, + ApiVersion: req.ApiVersion, + DisplayName: req.DisplayName, + Spec: spec, Path: path, SpecCatalogItemId: req.Spec.CatalogItemId, } } +func userValueAPIToModel(uv v1alpha1.UserValue) model.UserValue { + return model.UserValue{ + Resource: uv.Resource, + Path: uv.Path, + Value: uv.Value, + } +} + +func userValueModelToAPI(uv model.UserValue) v1alpha1.UserValue { + return v1alpha1.UserValue{ + Resource: uv.Resource, + Path: uv.Path, + Value: uv.Value, + } +} + // catalogItemInstanceToAPIType converts a store model to an API type func catalogItemInstanceToAPIType(m *model.CatalogItemInstance) v1alpha1.CatalogItemInstance { userValues := make([]v1alpha1.UserValue, len(m.Spec.UserValues)) for i, uv := range m.Spec.UserValues { - userValues[i] = v1alpha1.UserValue{ - Path: uv.Path, - Value: uv.Value, - } + userValues[i] = userValueModelToAPI(uv) + } + + spec := v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: m.Spec.CatalogItemId, + UserValues: userValues, + } + if len(m.Spec.ResourceIDs) > 0 { + ids := append([]string(nil), m.Spec.ResourceIDs...) + spec.ResourceIds = &ids } - apiType := v1alpha1.CatalogItemInstance{ + return v1alpha1.CatalogItemInstance{ ApiVersion: m.ApiVersion, DisplayName: m.DisplayName, - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: m.Spec.CatalogItemId, - UserValues: userValues, - }, - ResourceId: &m.ResourceID, - Path: &m.Path, - Uid: &m.ID, - CreateTime: &m.CreateTime, - UpdateTime: &m.UpdateTime, + Spec: spec, + Path: &m.Path, + Uid: &m.ID, + CreateTime: &m.CreateTime, + UpdateTime: &m.UpdateTime, } - - return apiType } // mapCatalogItemInstanceStoreError converts store errors to service domain errors diff --git a/internal/catalog/service/catalog_item_instance_test.go b/internal/catalog/service/catalog_item_instance_test.go index 9175345..eb05b5f 100644 --- a/internal/catalog/service/catalog_item_instance_test.go +++ b/internal/catalog/service/catalog_item_instance_test.go @@ -18,6 +18,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/service" "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) // mockPMClient is a mock Placement Manager client for testing @@ -54,17 +55,31 @@ func (m *mockPMClient) RehydrateResource(ctx context.Context, resourceID string, return &placement.Resource{ID: newResourceID}, nil } +func seedCatalogItemInstance(ctx context.Context, str store.Store, id string, resourceIDs []string) { + _, err := str.CatalogItemInstance().Create(ctx, model.CatalogItemInstance{ + ID: id, + ApiVersion: "v1alpha1", + DisplayName: "Seeded instance", + Spec: model.CatalogItemInstanceSpec{ + CatalogItemId: "small-vm", + UserValues: []model.UserValue{}, + ResourceIDs: append([]string(nil), resourceIDs...), + }, + Path: fmt.Sprintf("catalog-item-instances/%s", id), + SpecCatalogItemId: "small-vm", + }) + if err != nil { + panic(err) + } +} + func ensureCatalogItem(ctx context.Context, str store.Store, id, serviceType string) { ci := model.CatalogItem{ ID: id, ApiVersion: "v1alpha1", DisplayName: fmt.Sprintf("Test %s", id), - Spec: model.CatalogItemSpec{ - ServiceType: serviceType, - Fields: []model.FieldConfiguration{}, - }, - Path: fmt.Sprintf("catalog-items/%s", id), - SpecServiceType: serviceType, + Spec: testutil.ModelCatalogSpec(serviceType, []model.FieldConfiguration{}), + Path: fmt.Sprintf("catalog-items/%s", id), } _, err := str.CatalogItem().Create(ctx, ci) if err != nil { @@ -77,12 +92,8 @@ func ensureCatalogItemWithFields(ctx context.Context, str store.Store, id, servi ID: id, ApiVersion: "v1alpha1", DisplayName: fmt.Sprintf("Test %s", id), - Spec: model.CatalogItemSpec{ - ServiceType: serviceType, - Fields: fields, - }, - Path: fmt.Sprintf("catalog-items/%s", id), - SpecServiceType: serviceType, + Spec: testutil.ModelCatalogSpec(serviceType, fields), + Path: fmt.Sprintf("catalog-items/%s", id), } _, err := str.CatalogItem().Create(ctx, ci) if err != nil { @@ -104,6 +115,22 @@ func ensureServiceTypeWithSpec(ctx context.Context, str store.Store, id, service } } +func ensureMultiResourceCatalogItem(ctx context.Context, str store.Store, id string, resources []model.CatalogResource) { + ci := model.CatalogItem{ + ID: id, + ApiVersion: "v1alpha1", + DisplayName: fmt.Sprintf("Test %s", id), + Spec: model.CatalogItemSpec{ + Resources: resources, + }, + Path: fmt.Sprintf("catalog-items/%s", id), + } + _, err := str.CatalogItem().Create(ctx, ci) + if err != nil { + return + } +} + var _ = Describe("CatalogItemInstance Service", func() { var ( ctx context.Context @@ -168,8 +195,10 @@ var _ = Describe("CatalogItemInstance Service", func() { Expect(result.DisplayName).To(Equal("My VM Instance")) Expect(result.Spec.CatalogItemId).To(Equal("small-vm")) Expect(*result.Path).To(Equal("catalog-item-instances/my-instance")) - Expect(result.ResourceId).ToNot(BeNil()) - Expect(*result.ResourceId).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) + Expect(result.Spec.ResourceIds).ToNot(BeNil()) + Expect(*result.Spec.ResourceIds).To(HaveLen(1)) + Expect((*result.Spec.ResourceIds)[0]).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) + Expect(mockPM.createCalls).To(Equal(1)) }) }) @@ -188,9 +217,11 @@ var _ = Describe("CatalogItemInstance Service", func() { Expect(err).ToNot(HaveOccurred()) Expect(result.Uid).ToNot(BeNil()) Expect(*result.Uid).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) - Expect(result.ResourceId).ToNot(BeNil()) - Expect(*result.ResourceId).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) - Expect(*result.ResourceId).ToNot(Equal(*result.Uid)) + Expect(result.Spec.ResourceIds).ToNot(BeNil()) + Expect(*result.Spec.ResourceIds).To(HaveLen(1)) + Expect((*result.Spec.ResourceIds)[0]).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) + Expect(mockPM.createCalls).To(Equal(1)) + Expect((*result.Spec.ResourceIds)[0]).ToNot(Equal(*result.Uid)) }) }) @@ -223,7 +254,6 @@ var _ = Describe("CatalogItemInstance Service", func() { Expect(result).To(BeNil()) // Make sure create was called only once (for the first request) Expect(mockPM.createCalls).To(Equal(1)) - // Make sure delete was not called (since the second request fast-failed) Expect(mockPM.deleteCalls).To(Equal(0)) }) }) @@ -259,7 +289,7 @@ var _ = Describe("CatalogItemInstance Service", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "vm-with-fields", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, }, }, } @@ -280,7 +310,7 @@ var _ = Describe("CatalogItemInstance Service", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "vm-no-disk", UserValues: []v1alpha1.UserValue{ - {Path: "spec.disk.size", Value: float64(100)}, + {Resource: testutil.DefaultResourceName, Path: "spec.disk.size", Value: float64(100)}, }, }, } @@ -304,7 +334,7 @@ var _ = Describe("CatalogItemInstance Service", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "vm-immutable", UserValues: []v1alpha1.UserValue{ - {Path: "spec.memory.size_gb", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(16)}, }, }, } @@ -337,7 +367,7 @@ var _ = Describe("CatalogItemInstance Service", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "vm-validated", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(32)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(32)}, }, }, } @@ -370,7 +400,7 @@ var _ = Describe("CatalogItemInstance Service", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "vm-valid-schema", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, }, }, } @@ -400,6 +430,159 @@ var _ = Describe("CatalogItemInstance Service", func() { Expect(result).ToNot(BeNil()) }) }) + + Context("multi-resource catalog item", func() { + BeforeEach(func() { + ensureServiceTypeWithSpec(ctx, str, "db-st", "database", map[string]any{ + "engine": "postgres", + "version": "14", + }) + ensureMultiResourceCatalogItem(ctx, str, "dev-app", []model.CatalogResource{ + { + Name: "ordersDb", + ServiceType: "database", + Fields: []model.FieldConfiguration{ + {Path: "engine", Default: "postgres", Editable: true}, + {Path: "version", Default: "16", Editable: true}, + }, + }, + { + Name: "app", + ServiceType: "container", + RequiresResources: []string{"ordersDb"}, + Fields: []model.FieldConfiguration{ + {Path: "image", Default: "registry.example.com/app:1.0"}, + }, + }, + }) + }) + + It("should assign resource_ids for each resolved resource and call PM once", func() { + req := &service.CreateCatalogItemInstanceRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Dev App Instance", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: "dev-app", + UserValues: []v1alpha1.UserValue{}, + }, + } + + result, err := svc.CatalogItemInstance().Create(ctx, req) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.Spec.CatalogItemId).To(Equal("dev-app")) + Expect(mockPM.createCalls).To(Equal(1)) + Expect(result.Spec.ResourceIds).ToNot(BeNil()) + Expect(*result.Spec.ResourceIds).To(HaveLen(2)) + for _, rid := range *result.Spec.ResourceIds { + Expect(rid).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) + } + }) + + It("should accept user_values with resource and path", func() { + resource := "ordersDb" + req := &service.CreateCatalogItemInstanceRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Dev App Override", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: "dev-app", + UserValues: []v1alpha1.UserValue{ + {Resource: resource, Path: "version", Value: "17"}, + }, + }, + } + + result, err := svc.CatalogItemInstance().Create(ctx, req) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(mockPM.createCalls).To(Equal(1)) + }) + + It("should reject user_value without resource", func() { + req := &service.CreateCatalogItemInstanceRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Missing resource", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: "dev-app", + UserValues: []v1alpha1.UserValue{ + {Path: "version", Value: "17"}, + }, + }, + } + + result, err := svc.CatalogItemInstance().Create(ctx, req) + Expect(err).To(Equal(service.ErrUserValueResourceRequired)) + Expect(result).To(BeNil()) + Expect(mockPM.createCalls).To(Equal(0)) + }) + + It("should reject user_value for unknown resource", func() { + resource := "unknown" + req := &service.CreateCatalogItemInstanceRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Bad resource", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: "dev-app", + UserValues: []v1alpha1.UserValue{ + {Resource: resource, Path: "version", Value: "17"}, + }, + }, + } + + result, err := svc.CatalogItemInstance().Create(ctx, req) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("user value resource not found")) + Expect(result).To(BeNil()) + Expect(mockPM.createCalls).To(Equal(0)) + }) + + It("should delete the first placement resource", func() { + instanceID := "multi-resource-delete" + _, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ + ID: &instanceID, + ApiVersion: "v1alpha1", + DisplayName: "Multi-resource Delete", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: "dev-app", + UserValues: []v1alpha1.UserValue{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + mockPM.deleteCalls = 0 + + err = svc.CatalogItemInstance().Delete(ctx, instanceID) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPM.deleteCalls).To(Equal(1)) + + _, err = svc.CatalogItemInstance().Get(ctx, instanceID) + Expect(err).To(Equal(service.ErrCatalogItemInstanceNotFound)) + }) + + It("should rehydrate only the first placement resource", func() { + instanceID := "multi-resource-rehydrate" + created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ + ID: &instanceID, + ApiVersion: "v1alpha1", + DisplayName: "Multi-resource Rehydrate", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: "dev-app", + UserValues: []v1alpha1.UserValue{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(created.Spec.ResourceIds).ToNot(BeNil()) + Expect(*created.Spec.ResourceIds).To(HaveLen(2)) + originalResourceIDs := append([]string(nil), *created.Spec.ResourceIds...) + + result, err := svc.CatalogItemInstance().Rehydrate(ctx, instanceID) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Spec.ResourceIds).ToNot(BeNil()) + Expect(*result.Spec.ResourceIds).To(HaveLen(2)) + Expect((*result.Spec.ResourceIds)[0]).NotTo(Equal(originalResourceIDs[0])) + Expect((*result.Spec.ResourceIds)[1]).To(Equal(originalResourceIDs[1])) + Expect(mockPM.rehydrateCalls).To(Equal(1)) + }) + }) }) Describe("List", func() { @@ -521,8 +704,8 @@ var _ = Describe("CatalogItemInstance Service", func() { Expect(result).ToNot(BeNil()) Expect(*result.Uid).To(Equal(*created.Uid)) Expect(result.DisplayName).To(Equal("Test Instance")) - Expect(result.ResourceId).ToNot(BeNil()) - Expect(*result.ResourceId).To(Equal(*created.ResourceId)) + Expect(result.Spec.ResourceIds).ToNot(BeNil()) + Expect(*result.Spec.ResourceIds).To(Equal(*created.Spec.ResourceIds)) }) }) @@ -606,20 +789,12 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { }) Describe("Create with PM", func() { - It("should call PM with separate resource ID and store it", func() { - var capturedReq placement.CreateResourceRequest - var capturedID string - mockPM.createFunc = func(_ context.Context, req placement.CreateResourceRequest, id string) (*placement.Resource, error) { - capturedReq = req - capturedID = id - return &placement.Resource{ID: id}, nil - } - - instanceID := "my-pm-instance" + It("should persist instance and call PM for each resolved resource", func() { + instanceID := "graph-pending-instance" req := &service.CreateCatalogItemInstanceRequest{ ID: &instanceID, ApiVersion: "v1alpha1", - DisplayName: "PM Test Instance", + DisplayName: "Graph Pending", Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "small-vm", UserValues: []v1alpha1.UserValue{}, @@ -629,150 +804,14 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { result, err := svc.CatalogItemInstance().Create(ctx, req) Expect(err).ToNot(HaveOccurred()) Expect(result).ToNot(BeNil()) - Expect(capturedReq.CatalogItemInstanceID).To(Equal(instanceID)) - // Resource ID passed to PM should be a UUID, different from instance ID - Expect(capturedID).ToNot(Equal(instanceID)) - Expect(capturedID).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) - Expect(capturedReq.Spec).ToNot(BeNil()) - // Resource ID should be stored and returned in the API response - Expect(result.ResourceId).ToNot(BeNil()) - Expect(*result.ResourceId).To(Equal(capturedID)) - - // Verify the resource ID is stored and returned in the API response + Expect(mockPM.createCalls).To(Equal(1)) + Expect(result.Spec.ResourceIds).ToNot(BeNil()) + Expect(*result.Spec.ResourceIds).To(HaveLen(1)) + Expect((*result.Spec.ResourceIds)[0]).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) + got, err := svc.CatalogItemInstance().Get(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) - Expect(got).ToNot(BeNil()) - Expect(*got.ResourceId).To(Equal(capturedID)) - }) - - It("should delete DB record when PM create fails with canceled request context", func() { - reqCtx, cancelReq := context.WithCancel(ctx) - mockPM.createFunc = func(context.Context, placement.CreateResourceRequest, string) (*placement.Resource, error) { - cancelReq() - return nil, context.Canceled - } - - instanceID := "pm-ctx-cancel-rollback" - req := &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Context Cancel", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - } - - result, err := svc.CatalogItemInstance().Create(reqCtx, req) - Expect(err).To(HaveOccurred()) - Expect(result).To(BeNil()) - - _, getErr := svc.CatalogItemInstance().Get(ctx, instanceID) - Expect(getErr).To(Equal(service.ErrCatalogItemInstanceNotFound)) - }) - - It("should delete DB record when PM create fails", func() { - mockPM.createFunc = func(_ context.Context, _ placement.CreateResourceRequest, _ string) (*placement.Resource, error) { - return nil, errors.New("PM unavailable") - } - - instanceID := "pm-fail-instance" - req := &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Fail Test", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - } - - result, err := svc.CatalogItemInstance().Create(ctx, req) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("placement manager create resource failed")) - Expect(result).To(BeNil()) - - // Verify DB record was cleaned up (rollback) - _, getErr := svc.CatalogItemInstance().Get(ctx, instanceID) - Expect(getErr).To(Equal(service.ErrCatalogItemInstanceNotFound)) - }) - - It("should return ErrPlacementManagerPolicyRejected when PM create returns 406", func() { - mockPM.createFunc = func(_ context.Context, _ placement.CreateResourceRequest, _ string) (*placement.Resource, error) { - return nil, &placement.PlacementError{StatusCode: 406, Body: "policy rejected"} - } - - instanceID := "pm-policy-fail" - req := &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Policy Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - } - - result, err := svc.CatalogItemInstance().Create(ctx, req) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, service.ErrPlacementManagerPolicyRejected)).To(BeTrue()) - Expect(result).To(BeNil()) - - // Verify DB record was cleaned up (rollback) - _, getErr := svc.CatalogItemInstance().Get(ctx, instanceID) - Expect(getErr).To(Equal(service.ErrCatalogItemInstanceNotFound)) - }) - - It("should return ErrPlacementManagerProviderError when PM create returns 422", func() { - mockPM.createFunc = func(_ context.Context, _ placement.CreateResourceRequest, _ string) (*placement.Resource, error) { - return nil, &placement.PlacementError{StatusCode: 422, Body: "provider error"} - } - - instanceID := "pm-provider-fail" - req := &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Provider Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - } - - result, err := svc.CatalogItemInstance().Create(ctx, req) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, service.ErrPlacementManagerProviderError)).To(BeTrue()) - Expect(result).To(BeNil()) - - // Verify DB record was cleaned up (rollback) - _, getErr := svc.CatalogItemInstance().Get(ctx, instanceID) - Expect(getErr).To(Equal(service.ErrCatalogItemInstanceNotFound)) - }) - - It("should return ErrPlacementManagerPolicyDependency when PM create returns 424", func() { - mockPM.createFunc = func(_ context.Context, _ placement.CreateResourceRequest, _ string) (*placement.Resource, error) { - return nil, &placement.PlacementError{StatusCode: 424, Body: "policy dependency"} - } - - instanceID := "pm-dependency-fail" - req := &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Dependency Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - } - - result, err := svc.CatalogItemInstance().Create(ctx, req) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, service.ErrPlacementManagerPolicyDependency)).To(BeTrue()) - Expect(result).To(BeNil()) - - // Verify DB record was cleaned up (rollback) - _, getErr := svc.CatalogItemInstance().Get(ctx, instanceID) - Expect(getErr).To(Equal(service.ErrCatalogItemInstanceNotFound)) + Expect(*got.Spec.ResourceIds).To(Equal(*result.Spec.ResourceIds)) }) }) @@ -787,17 +826,8 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { } instanceID := "rehydrate-instance" - created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "Rehydrate Test", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - oldResourceID := *created.ResourceId + oldResourceID := "resource-before-rehydrate" + seedCatalogItemInstance(ctx, str, instanceID, []string{oldResourceID}) result, err := svc.CatalogItemInstance().Rehydrate(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) @@ -809,16 +839,14 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { Expect(capturedNewResourceID).ToNot(Equal(oldResourceID)) Expect(capturedNewResourceID).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) // Result has the new resource ID - Expect(*result.ResourceId).To(Equal(capturedNewResourceID)) - // Instance ID unchanged - Expect(*result.Uid).To(Equal(instanceID)) + Expect(*result.Spec.ResourceIds).To(Equal([]string{capturedNewResourceID})) Expect(mockPM.rehydrateCalls).To(Equal(1)) // Verify persisted got, err := svc.CatalogItemInstance().Get(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) - Expect(*got.ResourceId).To(Equal(capturedNewResourceID)) + Expect(*got.Spec.ResourceIds).To(Equal(*result.Spec.ResourceIds)) }) It("should return ErrCatalogItemInstanceNotFound for non-existent instance", func() { @@ -830,28 +858,20 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { It("should rollback resource_id and not call PM when a second rehydrate races", func() { instanceID := "rehydrate-conflict" - created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "Conflict Test", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) + oldResourceID := "resource-conflict-old" + seedCatalogItemInstance(ctx, str, instanceID, []string{oldResourceID}) // First rehydrate succeeds result, err := svc.CatalogItemInstance().Rehydrate(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) - newResourceID := *result.ResourceId - Expect(newResourceID).ToNot(Equal(*created.ResourceId)) + newResourceIDs := *result.Spec.ResourceIds + Expect(newResourceIDs[0]).ToNot(Equal(oldResourceID)) // Simulate a concurrent caller that read the old resource_id before // the first rehydrate committed — manually revert DB to old resource_id // to set up the CAS conflict scenario directStore := str.CatalogItemInstance() - _, err = directStore.UpdateResourceID(ctx, instanceID, newResourceID, *created.ResourceId) + _, err = directStore.UpdateResourceID(ctx, instanceID, newResourceIDs[0], oldResourceID) Expect(err).ToNot(HaveOccurred()) // Now rehydrate again — this should succeed since resource_id matches @@ -859,22 +879,13 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { result2, err := svc.CatalogItemInstance().Rehydrate(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) Expect(mockPM.rehydrateCalls).To(Equal(1)) - Expect(*result2.ResourceId).ToNot(Equal(*created.ResourceId)) + Expect(*result2.Spec.ResourceIds).ToNot(Equal([]string{oldResourceID})) }) It("should rollback resource_id when PM rehydrate fails", func() { instanceID := "rehydrate-pm-fail" - created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Rehydrate Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - oldResourceID := *created.ResourceId + oldResourceID := "resource-rehydrate-pm-fail" + seedCatalogItemInstance(ctx, str, instanceID, []string{oldResourceID}) mockPM.rehydrateFunc = func(_ context.Context, _ string, _ string) (*placement.Resource, error) { return nil, errors.New("PM rehydrate unavailable") @@ -888,22 +899,13 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { // Verify resource_id rolled back to original got, err := svc.CatalogItemInstance().Get(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) - Expect(*got.ResourceId).To(Equal(oldResourceID)) + Expect(*got.Spec.ResourceIds).To(Equal([]string{oldResourceID})) }) It("should return ErrPlacementManagerPolicyRejected when PM rehydrate returns 406", func() { instanceID := "rehydrate-policy-fail" - created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Rehydrate Policy Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - oldResourceID := *created.ResourceId + oldResourceID := "resource-rehydrate-policy-fail" + seedCatalogItemInstance(ctx, str, instanceID, []string{oldResourceID}) mockPM.rehydrateFunc = func(_ context.Context, _ string, _ string) (*placement.Resource, error) { return nil, &placement.PlacementError{StatusCode: 406, Body: "policy rejected"} @@ -917,22 +919,13 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { // Verify resource_id rolled back got, err := svc.CatalogItemInstance().Get(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) - Expect(*got.ResourceId).To(Equal(oldResourceID)) + Expect(*got.Spec.ResourceIds).To(Equal([]string{oldResourceID})) }) It("should return ErrPlacementManagerProviderError when PM rehydrate returns 422", func() { instanceID := "rehydrate-provider-fail" - created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Rehydrate Provider Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - oldResourceID := *created.ResourceId + oldResourceID := "resource-rehydrate-provider-fail" + seedCatalogItemInstance(ctx, str, instanceID, []string{oldResourceID}) mockPM.rehydrateFunc = func(_ context.Context, _ string, _ string) (*placement.Resource, error) { return nil, &placement.PlacementError{StatusCode: 422, Body: "provider error"} @@ -946,22 +939,13 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { // Verify resource_id rolled back got, err := svc.CatalogItemInstance().Get(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) - Expect(*got.ResourceId).To(Equal(oldResourceID)) + Expect(*got.Spec.ResourceIds).To(Equal([]string{oldResourceID})) }) It("should return ErrPlacementManagerPolicyDependency when PM rehydrate returns 424", func() { instanceID := "rehydrate-dependency-fail" - created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Rehydrate Dependency Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - oldResourceID := *created.ResourceId + oldResourceID := "resource-rehydrate-dependency-fail" + seedCatalogItemInstance(ctx, str, instanceID, []string{oldResourceID}) mockPM.rehydrateFunc = func(_ context.Context, _ string, _ string) (*placement.Resource, error) { return nil, &placement.PlacementError{StatusCode: 424, Body: "policy dependency"} @@ -975,40 +959,25 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { // Verify resource_id rolled back got, err := svc.CatalogItemInstance().Get(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) - Expect(*got.ResourceId).To(Equal(oldResourceID)) + Expect(*got.Spec.ResourceIds).To(Equal([]string{oldResourceID})) }) }) Describe("Delete with PM", func() { It("should delete PM resource using stored resource ID then local record", func() { - var createdResourceID string var deletedResourceID string - mockPM.createFunc = func(_ context.Context, _ placement.CreateResourceRequest, id string) (*placement.Resource, error) { - createdResourceID = id - return &placement.Resource{ID: id}, nil - } + storedResourceID := "resource-delete-pm" mockPM.deleteFunc = func(_ context.Context, resourceID string) error { deletedResourceID = resourceID return nil } instanceID := "delete-pm-instance" - _, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "To Delete", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) + seedCatalogItemInstance(ctx, str, instanceID, []string{storedResourceID}) - err = svc.CatalogItemInstance().Delete(ctx, instanceID) + err := svc.CatalogItemInstance().Delete(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) - // Delete should use the stored resource ID, not the instance ID - Expect(deletedResourceID).ToNot(Equal(instanceID)) - Expect(deletedResourceID).To(Equal(createdResourceID)) + Expect(deletedResourceID).To(Equal(storedResourceID)) // Verify local record deleted _, getErr := svc.CatalogItemInstance().Get(ctx, instanceID) @@ -1016,28 +985,15 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { }) It("should not delete local record when PM delete fails", func() { - mockPM.createFunc = func(_ context.Context, _ placement.CreateResourceRequest, _ string) (*placement.Resource, error) { - return &placement.Resource{ID: "pm-fail-delete"}, nil - } - instanceID := "pm-delete-fail" - _, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Delete Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) + seedCatalogItemInstance(ctx, str, instanceID, []string{"resource-pm-delete-fail"}) // Make PM delete fail mockPM.deleteFunc = func(_ context.Context, _ string) error { return errors.New("PM delete unavailable") } - err = svc.CatalogItemInstance().Delete(ctx, instanceID) + err := svc.CatalogItemInstance().Delete(ctx, instanceID) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("placement manager delete resource failed")) diff --git a/internal/catalog/service/catalog_item_spec.go b/internal/catalog/service/catalog_item_spec.go new file mode 100644 index 0000000..72cadc5 --- /dev/null +++ b/internal/catalog/service/catalog_item_spec.go @@ -0,0 +1,54 @@ +package service + +import ( + "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/store/model" +) + +func catalogItemSpecAPIToModel(spec v1alpha1.CatalogItemSpec) model.CatalogItemSpec { + return model.CatalogItemSpec{ + Resources: catalogResourceAPIToModel(spec.Resources), + } +} + +func catalogItemSpecModelToAPI(spec model.CatalogItemSpec) v1alpha1.CatalogItemSpec { + return v1alpha1.CatalogItemSpec{ + Resources: catalogResourceModelToAPI(spec.Resources), + } +} + +func catalogResourceAPIToModel(resources []v1alpha1.CatalogResource) []model.CatalogResource { + out := make([]model.CatalogResource, len(resources)) + for i, r := range resources { + out[i] = model.CatalogResource{ + Name: r.Name, + ServiceType: r.ServiceType, + } + if r.RequiresResources != nil { + out[i].RequiresResources = append([]string(nil), *r.RequiresResources...) + } + if r.Fields != nil { + out[i].Fields = FieldConfigurationsToModel(*r.Fields) + } + } + return out +} + +func catalogResourceModelToAPI(resources []model.CatalogResource) []v1alpha1.CatalogResource { + out := make([]v1alpha1.CatalogResource, len(resources)) + for i, r := range resources { + out[i] = v1alpha1.CatalogResource{ + Name: r.Name, + ServiceType: r.ServiceType, + } + if len(r.RequiresResources) > 0 { + req := append([]string(nil), r.RequiresResources...) + out[i].RequiresResources = &req + } + if len(r.Fields) > 0 { + fields := FieldConfigurationsFromModel(r.Fields) + out[i].Fields = &fields + } + } + return out +} diff --git a/internal/catalog/service/catalog_item_test.go b/internal/catalog/service/catalog_item_test.go index 4ea44f4..5fd7281 100644 --- a/internal/catalog/service/catalog_item_test.go +++ b/internal/catalog/service/catalog_item_test.go @@ -17,6 +17,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/service" "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) func ensureServiceType(ctx context.Context, str store.Store, id, serviceType string) { @@ -34,14 +35,37 @@ func ensureServiceType(ctx context.Context, str store.Store, id, serviceType str } } +func devAppCatalogItemSpec() v1alpha1.CatalogItemSpec { + requiresOrdersDb := []string{"ordersDb"} + return v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ + { + Name: "ordersDb", + ServiceType: "database", + Fields: &[]v1alpha1.FieldConfiguration{ + {Path: "engine", Default: "postgres"}, + {Path: "version", Default: "16"}, + }, + }, + { + Name: "app", + ServiceType: "container", + RequiresResources: &requiresOrdersDb, + Fields: &[]v1alpha1.FieldConfiguration{ + {Path: "image", Default: "registry.example.com/app:1.0"}, + }, + }, + }, + } +} + var _ = Describe("CatalogItem Service", func() { var ( - ctx context.Context - db *gorm.DB - str store.Store - svc service.Service - serviceTypeVM = "vm" - serviceTypeContainer = "container" + ctx context.Context + db *gorm.DB + str store.Store + svc service.Service + serviceTypeVM = "vm" ) BeforeEach(func() { @@ -78,12 +102,9 @@ var _ = Describe("CatalogItem Service", func() { ID: &userID, ApiVersion: "v1alpha1", DisplayName: displayName, - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.vcpu.count", Default: 2}, - }, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + {Path: "spec.vcpu.count", Default: 2}, + }), } result, err := svc.CatalogItem().Create(ctx, req) @@ -91,8 +112,8 @@ var _ = Describe("CatalogItem Service", func() { Expect(result).ToNot(BeNil()) Expect(*result.Uid).To(Equal(userID)) Expect(*result.DisplayName).To(Equal(displayName)) - Expect(*result.Spec.ServiceType).To(Equal(serviceTypeVM)) - Expect(*result.Spec.Fields).To(HaveLen(1)) + Expect(result.Spec.Resources[0].ServiceType).To(Equal(serviceTypeVM)) + Expect(*result.Spec.Resources[0].Fields).To(HaveLen(1)) }) }) @@ -101,12 +122,9 @@ var _ = Describe("CatalogItem Service", func() { req := &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Auto ID Item", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeContainer, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.image", Default: "nginx"}, - }, - }, + Spec: testutil.CatalogSpec("container", []v1alpha1.FieldConfiguration{ + {Path: "spec.image", Default: "nginx"}, + }), } result, err := svc.CatalogItem().Create(ctx, req) @@ -123,12 +141,9 @@ var _ = Describe("CatalogItem Service", func() { ID: &id, ApiVersion: "v1alpha1", DisplayName: "First", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.vcpu", Default: 2}, - }, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + {Path: "spec.vcpu", Default: 2}, + }), } _, err := svc.CatalogItem().Create(ctx, req1) Expect(err).ToNot(HaveOccurred()) @@ -137,12 +152,9 @@ var _ = Describe("CatalogItem Service", func() { ID: &id, ApiVersion: "v1alpha1", DisplayName: "Second", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeContainer, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.image", Default: "nginx"}, - }, - }, + Spec: testutil.CatalogSpec("container", []v1alpha1.FieldConfiguration{ + {Path: "spec.image", Default: "nginx"}, + }), } result, err := svc.CatalogItem().Create(ctx, req2) Expect(err).To(Equal(service.ErrCatalogItemIDTaken)) @@ -152,16 +164,12 @@ var _ = Describe("CatalogItem Service", func() { Context("when store returns service type not found error", func() { It("should return ErrServiceTypeNotFound", func() { - serviceTypeNonexistent := "nonexistent" req := &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Nonexistent Service Type", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeNonexistent, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.vcpu", Default: 2}, - }, - }, + Spec: testutil.CatalogSpec("nonexistent", []v1alpha1.FieldConfiguration{ + {Path: "spec.vcpu", Default: 2}, + }), } result, err := svc.CatalogItem().Create(ctx, req) Expect(err).To(Equal(service.ErrServiceTypeNotFound)) @@ -176,19 +184,13 @@ var _ = Describe("CatalogItem Service", func() { _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Item 1", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) _, err = svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Item 2", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeContainer, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.image", Default: "nginx"}}, - }, + Spec: testutil.CatalogSpecContainer([]v1alpha1.FieldConfiguration{{Path: "spec.image", Default: "nginx"}}), }) Expect(err).ToNot(HaveOccurred()) @@ -203,19 +205,13 @@ var _ = Describe("CatalogItem Service", func() { _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "VM Item", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) _, err = svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Container Item", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeContainer, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.image", Default: "nginx"}}, - }, + Spec: testutil.CatalogSpecContainer([]v1alpha1.FieldConfiguration{{Path: "spec.image", Default: "nginx"}}), }) Expect(err).ToNot(HaveOccurred()) @@ -223,7 +219,7 @@ var _ = Describe("CatalogItem Service", func() { result, err := svc.CatalogItem().List(ctx, service.CatalogItemListOptions{ServiceType: &svcType}) Expect(err).ToNot(HaveOccurred()) Expect(result.CatalogItems).To(HaveLen(1)) - Expect(*result.CatalogItems[0].Spec.ServiceType).To(Equal(serviceTypeVM)) + Expect(result.CatalogItems[0].Spec.Resources[0].ServiceType).To(Equal(serviceTypeVM)) }) }) @@ -233,10 +229,7 @@ var _ = Describe("CatalogItem Service", func() { _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: fmt.Sprintf("Item %d", i), - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) } @@ -276,10 +269,7 @@ var _ = Describe("CatalogItem Service", func() { created, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Test Item", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) Expect(created.Uid).ToNot(BeNil()) @@ -309,10 +299,7 @@ var _ = Describe("CatalogItem Service", func() { ID: &id, ApiVersion: "v1alpha1", DisplayName: "Old Name", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) @@ -335,20 +322,14 @@ var _ = Describe("CatalogItem Service", func() { ID: &id, ApiVersion: "v1alpha1", DisplayName: "Name", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) - newSpec := &v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.vcpu", Default: 4}, - {Path: "spec.memory", Default: "8GB"}, - }, - } + newSpec := testutil.PtrCatalogSpec("vm", []v1alpha1.FieldConfiguration{ + {Path: "spec.vcpu", Default: 4}, + {Path: "spec.memory", Default: "8GB"}, + }) req := &service.UpdateCatalogItemRequest{ Spec: newSpec, } @@ -356,36 +337,30 @@ var _ = Describe("CatalogItem Service", func() { result, err := svc.CatalogItem().Update(ctx, "item1", req) Expect(err).ToNot(HaveOccurred()) Expect(result).ToNot(BeNil()) - Expect(*result.Spec.Fields).To(HaveLen(2)) + Expect(*result.Spec.Resources[0].Fields).To(HaveLen(2)) }) }) Context("attempting to update spec.service_type (immutable)", func() { - It("should return ErrImmutableFieldUpdate", func() { + It("should return ErrImmutableSpecStructureUpdate", func() { id := "item1" _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ID: &id, ApiVersion: "v1alpha1", DisplayName: "Name", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) - newSpec := &v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeContainer, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.image", Default: "nginx"}, - }, - } + newSpec := testutil.PtrCatalogSpec("container", []v1alpha1.FieldConfiguration{ + {Path: "spec.image", Default: "nginx"}, + }) req := &service.UpdateCatalogItemRequest{ Spec: newSpec, } result, err := svc.CatalogItem().Update(ctx, "item1", req) - Expect(err).To(Equal(service.ErrImmutableFieldUpdate)) + Expect(err).To(Equal(service.ErrImmutableSpecStructureUpdate)) Expect(result).To(BeNil()) }) }) @@ -410,33 +385,30 @@ var _ = Describe("CatalogItem Service", func() { req := &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Cyclic DependsOn", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - { - Path: "spec.vcpu.count", - Default: float64(2), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.memory.size_gb", - AllowedValues: map[string][]any{ - "4": {float64(2), float64(4)}, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + { + Path: "spec.vcpu.count", + Default: float64(2), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.memory.size_gb", + AllowedValues: map[string][]any{ + "4": {float64(2), float64(4)}, }, }, - { - Path: "spec.memory.size_gb", - Default: float64(4), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.vcpu.count", - AllowedValues: map[string][]any{ - "2": {float64(4), float64(8)}, - }, + }, + { + Path: "spec.memory.size_gb", + Default: float64(4), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.vcpu.count", + AllowedValues: map[string][]any{ + "2": {float64(4), float64(8)}, }, }, }, - }, + }), } result, err := svc.CatalogItem().Create(ctx, req) @@ -450,44 +422,41 @@ var _ = Describe("CatalogItem Service", func() { req := &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Three-Field Cycle", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - { - Path: "spec.vcpu.count", - Default: float64(2), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.memory.size_gb", - AllowedValues: map[string][]any{ - "4": {float64(2), float64(4)}, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + { + Path: "spec.vcpu.count", + Default: float64(2), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.memory.size_gb", + AllowedValues: map[string][]any{ + "4": {float64(2), float64(4)}, }, }, - { - Path: "spec.memory.size_gb", - Default: float64(4), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.disk.size_gb", - AllowedValues: map[string][]any{ - "100": {float64(4), float64(8)}, - }, + }, + { + Path: "spec.memory.size_gb", + Default: float64(4), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.disk.size_gb", + AllowedValues: map[string][]any{ + "100": {float64(4), float64(8)}, }, }, - { - Path: "spec.disk.size_gb", - Default: float64(100), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.vcpu.count", - AllowedValues: map[string][]any{ - "2": {float64(100), float64(200)}, - }, + }, + { + Path: "spec.disk.size_gb", + Default: float64(100), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.vcpu.count", + AllowedValues: map[string][]any{ + "2": {float64(100), float64(200)}, }, }, }, - }, + }), } result, err := svc.CatalogItem().Create(ctx, req) @@ -501,28 +470,25 @@ var _ = Describe("CatalogItem Service", func() { req := &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Valid DependsOn", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - { - Path: "spec.vcpu.count", - Default: float64(2), - Editable: &editable, - }, - { - Path: "spec.memory.size_gb", - Default: float64(4), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.vcpu.count", - AllowedValues: map[string][]any{ - "2": {float64(4), float64(8)}, - "4": {float64(8), float64(16)}, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + { + Path: "spec.vcpu.count", + Default: float64(2), + Editable: &editable, + }, + { + Path: "spec.memory.size_gb", + Default: float64(4), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.vcpu.count", + AllowedValues: map[string][]any{ + "2": {float64(4), float64(8)}, + "4": {float64(8), float64(16)}, }, }, }, - }, + }), } result, err := svc.CatalogItem().Create(ctx, req) @@ -537,22 +503,19 @@ var _ = Describe("CatalogItem Service", func() { req := &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Invalid DependsOn Path", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - { - Path: "spec.memory.size_gb", - Default: float64(4), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.region", - AllowedValues: map[string][]any{ - "us-central1": {float64(4), float64(8)}, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + { + Path: "spec.memory.size_gb", + Default: float64(4), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.region", + AllowedValues: map[string][]any{ + "us-central1": {float64(4), float64(8)}, }, }, }, - }, + }), } result, err := svc.CatalogItem().Create(ctx, req) @@ -572,46 +535,215 @@ var _ = Describe("CatalogItem Service", func() { ID: &id, ApiVersion: "v1alpha1", DisplayName: "No Cycle", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.vcpu.count", Default: float64(2), Editable: &editable}, - {Path: "spec.memory.size_gb", Default: float64(4), Editable: &editable}, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + {Path: "spec.vcpu.count", Default: float64(2), Editable: &editable}, + {Path: "spec.memory.size_gb", Default: float64(4), Editable: &editable}, + }), + }) + Expect(err).ToNot(HaveOccurred()) + + updateSpec := testutil.PtrCatalogSpec("vm", []v1alpha1.FieldConfiguration{ + { + Path: "spec.vcpu.count", + Default: float64(2), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.memory.size_gb", + AllowedValues: map[string][]any{ + "4": {float64(2), float64(4)}, + }, + }, + }, + { + Path: "spec.memory.size_gb", + Default: float64(4), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.vcpu.count", + AllowedValues: map[string][]any{ + "2": {float64(4), float64(8)}, + }, }, }, }) + + result, err := svc.CatalogItem().Update(ctx, id, &service.UpdateCatalogItemRequest{ + Spec: updateSpec, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cycle")) + Expect(result).To(BeNil()) + }) + }) + + Describe("Create multi-resource catalog item", func() { + BeforeEach(func() { + ensureServiceType(ctx, str, "db-st", "database") + }) + + It("should create a multi-resource catalog item with resources", func() { + id := "dev-app" + req := &service.CreateCatalogItemRequest{ + ID: &id, + ApiVersion: "v1alpha1", + DisplayName: "Dev Application", + Spec: devAppCatalogItemSpec(), + } + + result, err := svc.CatalogItem().Create(ctx, req) Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(*result.Uid).To(Equal(id)) + Expect(result.Spec).ToNot(BeNil()) + Expect(result.Spec.Resources).ToNot(BeNil()) + Expect(result.Spec.Resources).To(HaveLen(2)) + Expect((result.Spec.Resources)[0].Name).To(Equal("ordersDb")) + Expect((result.Spec.Resources)[1].RequiresResources).ToNot(BeNil()) + Expect(*(result.Spec.Resources)[1].RequiresResources).To(Equal([]string{"ordersDb"})) + }) - updateSpec := &v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ + It("should round-trip multi-resource spec on get", func() { + id := "dev-app-get" + _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ID: &id, + ApiVersion: "v1alpha1", + DisplayName: "Dev Application", + Spec: devAppCatalogItemSpec(), + }) + Expect(err).ToNot(HaveOccurred()) + + result, err := svc.CatalogItem().Get(ctx, id) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Spec.Resources).ToNot(BeNil()) + Expect(result.Spec.Resources).To(HaveLen(2)) + }) + + It("should reject empty resources", func() { + result, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Empty resources", + Spec: v1alpha1.CatalogItemSpec{Resources: []v1alpha1.CatalogResource{}}, + }) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCatalogItemSpecConflict)).To(BeTrue()) + Expect(result).To(BeNil()) + }) + + It("should reject duplicate resource names", func() { + spec := v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ + {Name: "ordersDb", ServiceType: "database"}, + {Name: "ordersDb", ServiceType: "container"}, + }, + } + + result, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Duplicate names", + Spec: spec, + }) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCatalogItemResourceNameTaken)).To(BeTrue()) + Expect(result).To(BeNil()) + }) + + It("should reject unknown requires_resources reference", func() { + requiresMissing := []string{"missing"} + spec := v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ { - Path: "spec.vcpu.count", - Default: float64(2), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.memory.size_gb", - AllowedValues: map[string][]any{ - "4": {float64(2), float64(4)}, - }, - }, + Name: "app", + ServiceType: "container", + RequiresResources: &requiresMissing, }, + }, + } + + result, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Bad requires", + Spec: spec, + }) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCatalogItemRequiresResourceNotFound)).To(BeTrue()) + Expect(result).To(BeNil()) + }) + + It("should reject cyclic requires_resources", func() { + requiresApp := []string{"app"} + requiresDb := []string{"ordersDb"} + spec := v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ + {Name: "ordersDb", ServiceType: "database", RequiresResources: &requiresApp}, + {Name: "app", ServiceType: "container", RequiresResources: &requiresDb}, + }, + } + + result, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Cycle", + Spec: spec, + }) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCatalogItemRequiresCycle)).To(BeTrue()) + Expect(result).To(BeNil()) + }) + + It("should reject resource with unknown service type", func() { + spec := v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ + {Name: "ordersDb", ServiceType: "nonexistent"}, + }, + } + + result, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Bad service type", + Spec: spec, + }) + Expect(err).To(Equal(service.ErrServiceTypeNotFound)) + Expect(result).To(BeNil()) + }) + + It("should reject cyclic depends_on within a resource fields", func() { + editable := true + spec := v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ { - Path: "spec.memory.size_gb", - Default: float64(4), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.vcpu.count", - AllowedValues: map[string][]any{ - "2": {float64(4), float64(8)}, + Name: "ordersDb", + ServiceType: "database", + Fields: &[]v1alpha1.FieldConfiguration{ + { + Path: "version", + Default: "16", + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "engine", + AllowedValues: map[string][]any{ + "postgres": {"14", "16"}, + }, + }, + }, + { + Path: "engine", + Default: "postgres", + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "version", + AllowedValues: map[string][]any{ + "16": {"postgres"}, + }, + }, }, }, }, }, } - result, err := svc.CatalogItem().Update(ctx, id, &service.UpdateCatalogItemRequest{ - Spec: updateSpec, + result, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Field cycle", + Spec: spec, }) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("cycle")) @@ -619,6 +751,117 @@ var _ = Describe("CatalogItem Service", func() { }) }) + Describe("Update multi-resource catalog item", func() { + BeforeEach(func() { + ensureServiceType(ctx, str, "db-st", "database") + id := "dev-app-update" + _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ID: &id, + ApiVersion: "v1alpha1", + DisplayName: "Dev Application", + Spec: devAppCatalogItemSpec(), + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should update field defaults within a resource", func() { + spec := devAppCatalogItemSpec() + (spec.Resources)[0].Fields = &[]v1alpha1.FieldConfiguration{ + {Path: "engine", Default: "mysql"}, + {Path: "version", Default: "8.0"}, + } + + result, err := svc.CatalogItem().Update(ctx, "dev-app-update", &service.UpdateCatalogItemRequest{ + Spec: &spec, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.Spec.Resources).To(HaveLen(2)) + Expect(*(result.Spec.Resources)[0].Fields).To(HaveLen(2)) + Expect((*(result.Spec.Resources)[0].Fields)[0].Default).To(Equal("mysql")) + }) + + It("should reject changing resource name", func() { + spec := devAppCatalogItemSpec() + (spec.Resources)[0].Name = "renamedDb" + + result, err := svc.CatalogItem().Update(ctx, "dev-app-update", &service.UpdateCatalogItemRequest{ + Spec: &spec, + }) + Expect(err).To(Equal(service.ErrImmutableSpecStructureUpdate)) + Expect(result).To(BeNil()) + }) + + It("should reject changing resource service type", func() { + spec := devAppCatalogItemSpec() + (spec.Resources)[0].ServiceType = "vm" + + result, err := svc.CatalogItem().Update(ctx, "dev-app-update", &service.UpdateCatalogItemRequest{ + Spec: &spec, + }) + Expect(err).To(Equal(service.ErrImmutableSpecStructureUpdate)) + Expect(result).To(BeNil()) + }) + + It("should reject changing requires_resources", func() { + spec := devAppCatalogItemSpec() + empty := []string{} + (spec.Resources)[1].RequiresResources = &empty + + result, err := svc.CatalogItem().Update(ctx, "dev-app-update", &service.UpdateCatalogItemRequest{ + Spec: &spec, + }) + Expect(err).To(Equal(service.ErrImmutableSpecStructureUpdate)) + Expect(result).To(BeNil()) + }) + }) + + Describe("List catalog items by resource service type", func() { + BeforeEach(func() { + ensureServiceType(ctx, str, "db-st", "database") + ensureServiceType(ctx, str, "ctr-st", "container") + }) + + It("returns items where any resource matches the filter", func() { + _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Single-resource VM", + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), + }) + Expect(err).ToNot(HaveOccurred()) + _, err = svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Dev Application", + Spec: devAppCatalogItemSpec(), + }) + Expect(err).ToNot(HaveOccurred()) + + vmFilter := "vm" + result, err := svc.CatalogItem().List(ctx, service.CatalogItemListOptions{ + ServiceType: &vmFilter, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result.CatalogItems).To(HaveLen(1)) + Expect(*result.CatalogItems[0].DisplayName).To(Equal("Single-resource VM")) + + dbFilter := "database" + result, err = svc.CatalogItem().List(ctx, service.CatalogItemListOptions{ + ServiceType: &dbFilter, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result.CatalogItems).To(HaveLen(1)) + Expect(*result.CatalogItems[0].DisplayName).To(Equal("Dev Application")) + + containerFilter := "container" + result, err = svc.CatalogItem().List(ctx, service.CatalogItemListOptions{ + ServiceType: &containerFilter, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result.CatalogItems).To(HaveLen(1)) + Expect(*result.CatalogItems[0].DisplayName).To(Equal("Dev Application")) + }) + }) + Describe("Delete", func() { Context("with existing item", func() { It("should delete the catalog item", func() { @@ -627,10 +870,7 @@ var _ = Describe("CatalogItem Service", func() { ID: &id, ApiVersion: "v1alpha1", DisplayName: "To Delete", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/catalog/service/catalog_item_validation.go b/internal/catalog/service/catalog_item_validation.go new file mode 100644 index 0000000..896143a --- /dev/null +++ b/internal/catalog/service/catalog_item_validation.go @@ -0,0 +1,201 @@ +package service + +import ( + "context" + "fmt" + + "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/store" + "github.com/dcm-project/control-plane/internal/catalog/store/model" +) + +// validateCatalogItemSpec checks a catalog item spec on create and update. +// Validates resources, required fields, and delegates to +// resource-specific rules. Does not build or order a DAG — that is +// placement's job at instance time. +func validateCatalogItemSpec(ctx context.Context, store store.Store, spec model.CatalogItemSpec) error { + return validateCatalogResources(ctx, store, spec.Resources) +} + +// validateCatalogResources validates a catalog at authoring time: +// unique resource names, resolvable service types, valid requires_resources +// references, per-resource depends_on cycles, and no cycles in +// requires_resources. CEL in field defaults is validated at instance merge time. +func validateCatalogResources(ctx context.Context, store store.Store, resources []model.CatalogResource) error { + if len(resources) == 0 { + return fmt.Errorf("%w: resources must not be empty", ErrCatalogItemSpecConflict) + } + + seen := make(map[string]bool, len(resources)) + for _, r := range resources { + if r.Name == "" { + return fmt.Errorf("%w: resource name is required", ErrCatalogItemSpecConflict) + } + if seen[r.Name] { + return fmt.Errorf("%w: %s", ErrCatalogItemResourceNameTaken, r.Name) + } + seen[r.Name] = true + + if r.ServiceType == "" { + return fmt.Errorf("%w: resource %s service_type is required", ErrCatalogItemSpecConflict, r.Name) + } + if _, err := store.ServiceType().GetByServiceType(ctx, r.ServiceType); err != nil { + return ErrServiceTypeNotFound + } + if err := validateFieldDependsOnCycles(r.Fields); err != nil { + return fmt.Errorf("resource %s: %w", r.Name, err) + } + } + + for _, r := range resources { + for _, dep := range r.RequiresResources { + if !seen[dep] { + return fmt.Errorf("%w: %s", ErrCatalogItemRequiresResourceNotFound, dep) + } + } + } + + if err := validateRequiresResourcesCycles(resources); err != nil { + return err + } + return nil +} + +// detectDirectedCycle reports a cycle in a directed graph where edges[node] lists +// predecessor nodes that node depends on (each must be satisfied before node). +func detectDirectedCycle(edges map[string][]string, cycleErr error) error { + if len(edges) == 0 { + return nil + } + + const ( + unvisited = 0 + visiting = 1 + visited = 2 + ) + state := make(map[string]int) + + var visit func(node string) error + visit = func(node string) error { + if state[node] == visited { + return nil + } + if state[node] == visiting { + return fmt.Errorf("%w: cycle involving %s", cycleErr, node) + } + state[node] = visiting + for _, dep := range edges[node] { + if err := visit(dep); err != nil { + return err + } + } + state[node] = visited + return nil + } + + for node := range edges { + if err := visit(node); err != nil { + return err + } + } + return nil +} + +// validateFieldDependsOnCycles checks that every depends_on path references an existing +// field and that there are no cyclic depends_on references within one field set. +func validateFieldDependsOnCycles(fields []model.FieldConfiguration) error { + knownPaths := make(map[string]bool, len(fields)) + for _, f := range fields { + knownPaths[f.Path] = true + } + + edges := make(map[string][]string) + for _, f := range fields { + if f.DependsOn == nil { + continue + } + depPath := f.DependsOn.Path + if !knownPaths[depPath] { + return fmt.Errorf("%w: field %s depends_on path %q not found in fields", ErrDependsOnPathNotFound, f.Path, depPath) + } + edges[f.Path] = []string{depPath} + } + + return detectDirectedCycle(edges, ErrDependsOnCycleDetected) +} + +// validateRequiresResourcesCycles detects cycles in requires_resources edges. +// Authoring-time guard only; placement repeats DAG validation when admitting a run. +func validateRequiresResourcesCycles(resources []model.CatalogResource) error { + edges := make(map[string][]string, len(resources)) + for _, r := range resources { + edges[r.Name] = append([]string(nil), r.RequiresResources...) + } + return detectDirectedCycle(edges, ErrCatalogItemRequiresCycle) +} + +// validateCatalogImmutable ensures structure is not changed on +// update (resource names, service types, requires_resources). Field defaults and +// validation rules within each resource may still change. +func validateCatalogImmutable(existing, updated model.CatalogItemSpec) error { + if len(existing.Resources) != len(updated.Resources) { + return ErrImmutableSpecStructureUpdate + } + + updatedByName := make(map[string]model.CatalogResource, len(updated.Resources)) + for _, r := range updated.Resources { + updatedByName[r.Name] = r + } + + for _, oldR := range existing.Resources { + newR, ok := updatedByName[oldR.Name] + if !ok { + return ErrImmutableSpecStructureUpdate + } + if oldR.ServiceType != newR.ServiceType || + !sameStringSlice(oldR.RequiresResources, newR.RequiresResources) { + return ErrImmutableSpecStructureUpdate + } + } + return nil +} + +func sameStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// userValuesForResource returns user values that target the given resource name. +func userValuesForResource(userValues []v1alpha1.UserValue, resourceName string) []v1alpha1.UserValue { + out := make([]v1alpha1.UserValue, 0) + for _, uv := range userValues { + if uv.Resource == resourceName { + out = append(out, uv) + } + } + return out +} + +// validateUserValuesForCatalogItem checks instance user_values against the catalog. +func validateUserValuesForCatalogItem(spec model.CatalogItemSpec, userValues []v1alpha1.UserValue) error { + known := make(map[string]bool, len(spec.Resources)) + for _, r := range spec.Resources { + known[r.Name] = true + } + for _, uv := range userValues { + if uv.Resource == "" { + return ErrUserValueResourceRequired + } + if !known[uv.Resource] { + return fmt.Errorf("%w: %s", ErrUserValueResourceNotFound, uv.Resource) + } + } + return nil +} diff --git a/internal/catalog/service/cel_validation.go b/internal/catalog/service/cel_validation.go new file mode 100644 index 0000000..84177f8 --- /dev/null +++ b/internal/catalog/service/cel_validation.go @@ -0,0 +1,106 @@ +package service + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/dcm-project/control-plane/internal/catalog/store" + "github.com/dcm-project/control-plane/internal/catalog/store/model" +) + +// celReferencePattern matches restricted catalog CEL: ${resourceName.outputField} +var celReferencePattern = regexp.MustCompile(`^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\}$`) + +type celReference struct { + ResourceName string + OutputField string +} + +func parseCELReference(value string) (celReference, bool, error) { + if !strings.Contains(value, "${") { + return celReference{}, false, nil + } + matches := celReferencePattern.FindStringSubmatch(value) + if matches == nil { + return celReference{}, true, fmt.Errorf("%w: %q", ErrInvalidCELExpression, value) + } + return celReference{ + ResourceName: matches[1], + OutputField: matches[2], + }, true, nil +} + +// serviceTypeOutputNames returns declared output field names from a service type. +// Reads optional spec.outputs until outputs are formally defined on ServiceType. +func serviceTypeOutputNames(st *model.ServiceType) map[string]bool { + outputs := make(map[string]bool) + raw, ok := st.Spec["outputs"] + if !ok { + return outputs + } + m, ok := raw.(map[string]any) + if !ok { + return outputs + } + for name := range m { + outputs[name] = true + } + return outputs +} + +func validateCELReferenceValue( + ctx context.Context, + store store.Store, + resourcesByName map[string]model.CatalogResource, + consumerResourceName string, + fieldPath string, + value any, +) error { + str, ok := value.(string) + if !ok { + return nil + } + + ref, isCEL, err := parseCELReference(str) + if err != nil { + return err + } + if !isCEL { + return nil + } + + if ref.ResourceName == consumerResourceName { + return fmt.Errorf("%w: field %s", ErrCELSelfReference, fieldPath) + } + + source, ok := resourcesByName[ref.ResourceName] + if !ok { + return fmt.Errorf("%w: %s", ErrCELResourceNotFound, ref.ResourceName) + } + + sourceST, err := store.ServiceType().GetByServiceType(ctx, source.ServiceType) + if err != nil { + return ErrServiceTypeNotFound + } + + outputs := serviceTypeOutputNames(sourceST) + if len(outputs) == 0 { + return fmt.Errorf("%w: service type %q has no declared outputs for %s.%s", + ErrCELServiceTypeOutputNotFound, source.ServiceType, ref.ResourceName, ref.OutputField) + } + if !outputs[ref.OutputField] { + return fmt.Errorf("%w: %s.%s", ErrCELServiceTypeOutputNotFound, ref.ResourceName, ref.OutputField) + } + + return nil +} + +func catalogResourcesByName(resources []model.CatalogResource) map[string]model.CatalogResource { + byName := make(map[string]model.CatalogResource, len(resources)) + for _, r := range resources { + byName[r.Name] = r + } + return byName +} diff --git a/internal/catalog/service/cel_validation_test.go b/internal/catalog/service/cel_validation_test.go new file mode 100644 index 0000000..1519241 --- /dev/null +++ b/internal/catalog/service/cel_validation_test.go @@ -0,0 +1,242 @@ +package service_test + +import ( + "context" + "errors" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/config" + "github.com/dcm-project/control-plane/internal/catalog/service" + "github.com/dcm-project/control-plane/internal/catalog/store" + "github.com/dcm-project/control-plane/internal/catalog/store/model" +) + +func serviceTypeSpecWithOutputs(base map[string]any, outputs map[string]any) map[string]any { + spec := make(map[string]any, len(base)+1) + for k, v := range base { + spec[k] = v + } + spec["outputs"] = outputs + return spec +} + +func devAppCatalogItemSpecWithCEL() v1alpha1.CatalogItemSpec { + requiresOrdersDb := []string{"ordersDb"} + return v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ + { + Name: "ordersDb", + ServiceType: "database", + Fields: &[]v1alpha1.FieldConfiguration{ + {Path: "engine", Default: "postgres"}, + }, + }, + { + Name: "app", + ServiceType: "container", + RequiresResources: &requiresOrdersDb, + Fields: &[]v1alpha1.FieldConfiguration{ + {Path: "database_url", Default: "${ordersDb.connectionString}"}, + }, + }, + }, + } +} + +var _ = Describe("CEL validation", func() { + var ( + ctx context.Context + db *gorm.DB + str store.Store + svc service.Service + ) + + BeforeEach(func() { + ctx = context.Background() + var err error + db, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Discard}) + Expect(err).ToNot(HaveOccurred()) + Expect(db.Exec("PRAGMA foreign_keys = ON").Error).To(Succeed()) + Expect(db.AutoMigrate(&model.ServiceType{}, &model.CatalogItem{}, &model.CatalogItemInstance{})).To(Succeed()) + str = store.NewStore(db, slog.Default()) + svc, err = service.NewService(str, &mockPMClient{}, config.DefaultSeedConfig(), slog.Default()) + Expect(err).ToNot(HaveOccurred()) + + ensureServiceTypeWithSpec(ctx, str, "db-cel", "database", serviceTypeSpecWithOutputs( + map[string]any{"engine": "postgres"}, + map[string]any{"connectionString": map[string]any{"type": "string"}}, + )) + ensureServiceTypeWithSpec(ctx, str, "ctr-cel", "container", map[string]any{ + "image": map[string]any{"reference": "nginx"}, + "database_url": "", + }) + }) + + AfterEach(func() { + if str != nil { + Expect(str.Close()).To(Succeed()) + } + }) + + createCatalogItemWithSpec := func(spec v1alpha1.CatalogItemSpec) string { + ci, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Dev App CEL", + Spec: spec, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(ci.Uid).ToNot(BeNil()) + return *ci.Uid + } + + instanceCreateReq := func(catalogItemID string) *service.CreateCatalogItemInstanceRequest { + return &service.CreateCatalogItemInstanceRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Instance", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: catalogItemID, + UserValues: []v1alpha1.UserValue{}, + }, + } + } + + Describe("catalog item create", func() { + It("accepts field defaults containing CEL without validating references", func() { + spec := devAppCatalogItemSpecWithCEL() + (*spec.Resources[1].Fields)[0].Default = "${missingDb.connectionString}" + req := &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Deferred CEL", + Spec: spec, + } + result, err := svc.CatalogItem().Create(ctx, req) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + }) + }) + + Describe("catalog instance create", func() { + It("creates instance when CEL defaults validate during merge", func() { + catalogItemID := createCatalogItemWithSpec(devAppCatalogItemSpecWithCEL()) + result, err := svc.CatalogItemInstance().Create(ctx, instanceCreateReq(catalogItemID)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + }) + + It("rejects malformed CEL expressions during merge", func() { + spec := devAppCatalogItemSpecWithCEL() + (*spec.Resources[1].Fields)[0].Default = "prefix-${ordersDb.connectionString}" + catalogItemID := createCatalogItemWithSpec(spec) + _, err := svc.CatalogItemInstance().Create(ctx, instanceCreateReq(catalogItemID)) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrInvalidCELExpression)).To(BeTrue()) + }) + + It("rejects CEL referencing unknown catalog resource during merge", func() { + spec := devAppCatalogItemSpecWithCEL() + (*spec.Resources[1].Fields)[0].Default = "${missingDb.connectionString}" + catalogItemID := createCatalogItemWithSpec(spec) + _, err := svc.CatalogItemInstance().Create(ctx, instanceCreateReq(catalogItemID)) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCELResourceNotFound)).To(BeTrue()) + }) + + It("rejects CEL referencing unknown service type output during merge", func() { + spec := devAppCatalogItemSpecWithCEL() + (*spec.Resources[1].Fields)[0].Default = "${ordersDb.connectionStrng}" + catalogItemID := createCatalogItemWithSpec(spec) + _, err := svc.CatalogItemInstance().Create(ctx, instanceCreateReq(catalogItemID)) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCELServiceTypeOutputNotFound)).To(BeTrue()) + }) + + It("rejects CEL self-reference during merge", func() { + spec := devAppCatalogItemSpecWithCEL() + (*spec.Resources[0].Fields)[0].Default = "${ordersDb.connectionString}" + catalogItemID := createCatalogItemWithSpec(spec) + _, err := svc.CatalogItemInstance().Create(ctx, instanceCreateReq(catalogItemID)) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCELSelfReference)).To(BeTrue()) + }) + + It("rejects CEL when source service type declares no outputs during merge", func() { + ensureServiceTypeWithSpec(ctx, str, "db-no-out", "database-no-outputs", map[string]any{ + "engine": "postgres", + }) + spec := v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ + {Name: "ordersDb", ServiceType: "database-no-outputs"}, + { + Name: "app", + ServiceType: "container", + Fields: &[]v1alpha1.FieldConfiguration{ + {Path: "database_url", Default: "${ordersDb.connectionString}"}, + }, + }, + }, + } + catalogItemID := createCatalogItemWithSpec(spec) + _, err := svc.CatalogItemInstance().Create(ctx, instanceCreateReq(catalogItemID)) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCELServiceTypeOutputNotFound)).To(BeTrue()) + }) + + It("rejects user_values containing CEL expressions", func() { + catalogItemID := createCatalogItemWithSpec(devAppCatalogItemSpecWithCEL()) + req := &service.CreateCatalogItemInstanceRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Bad User CEL", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: catalogItemID, + UserValues: []v1alpha1.UserValue{ + {Resource: "app", Path: "database_url", Value: "${ordersDb.connectionString}"}, + }, + }, + } + _, err := svc.CatalogItemInstance().Create(ctx, req) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrUserValueCELNotAllowed)).To(BeTrue()) + }) + }) + + Describe("BuildResourceGraph", func() { + It("preserves CEL reference in merged spec after validation", func() { + ci := model.CatalogItem{ + ID: "graph-cel", + ApiVersion: "v1alpha1", + DisplayName: "Graph CEL", + Path: "catalog-items/graph-cel", + Spec: model.CatalogItemSpec{ + Resources: []model.CatalogResource{ + {Name: "ordersDb", ServiceType: "database", Fields: []model.FieldConfiguration{ + {Path: "engine", Default: "postgres"}, + }}, + { + Name: "app", + ServiceType: "container", + RequiresResources: []string{"ordersDb"}, + Fields: []model.FieldConfiguration{ + {Path: "database_url", Default: "${ordersDb.connectionString}"}, + }, + }, + }, + }, + } + _, err := str.CatalogItem().Create(ctx, ci) + Expect(err).ToNot(HaveOccurred()) + + builder := service.NewSpecBuilderForTest(str) + graph, err := builder.BuildResourceGraph(ctx, "graph-cel", nil) + Expect(err).ToNot(HaveOccurred()) + Expect(graph).To(HaveLen(2)) + Expect(graph[1].Spec["database_url"]).To(Equal("${ordersDb.connectionString}")) + }) + }) +}) diff --git a/internal/catalog/service/errors.go b/internal/catalog/service/errors.go index 23d25cc..9758eb2 100644 --- a/internal/catalog/service/errors.go +++ b/internal/catalog/service/errors.go @@ -25,8 +25,8 @@ var ( // ErrCatalogItemHasInstances indicates a catalog item has existing instances ErrCatalogItemHasInstances = errors.New("catalog item has existing instances") - // ErrImmutableFieldUpdate indicates an attempt to change api_version or spec.service_type - ErrImmutableFieldUpdate = errors.New("cannot update immutable fields: api_version and spec.service_type are immutable") + // ErrImmutableSpecStructureUpdate indicates an attempt to change immutable catalog item structure + ErrImmutableSpecStructureUpdate = errors.New("cannot update immutable catalog item fields: resource names, service types, and requires_resources are immutable") // ErrCatalogItemInstanceNotFound indicates the requested catalog item instance does not exist ErrCatalogItemInstanceNotFound = errors.New("catalog item instance not found") @@ -55,9 +55,42 @@ var ( // ErrDependsOnPathNotFound indicates a depends_on path does not reference any field in the catalog item ErrDependsOnPathNotFound = errors.New("depends_on path does not reference an existing field") + // ErrCatalogItemSpecConflict indicates an invalid catalog item spec + ErrCatalogItemSpecConflict = errors.New("invalid catalog item spec") + + // ErrCatalogItemResourceNameTaken indicates duplicate resource names in a catalog item + ErrCatalogItemResourceNameTaken = errors.New("duplicate resource name in catalog item") + + // ErrCatalogItemRequiresResourceNotFound indicates requires_resources references an unknown resource name + ErrCatalogItemRequiresResourceNotFound = errors.New("requires_resources references unknown resource name") + + // ErrCatalogItemRequiresCycle indicates a cycle in requires_resources dependencies + ErrCatalogItemRequiresCycle = errors.New("cycle detected in requires_resources dependencies") + + // ErrUserValueResourceRequired indicates a user_value is missing the resource name + ErrUserValueResourceRequired = errors.New("user value resource is required") + + // ErrUserValueResourceNotFound indicates a user_value resource does not match any catalog resource + ErrUserValueResourceNotFound = errors.New("user value resource not found in catalog item") + // ErrUserValueDependsOnViolation indicates the user value is not allowed given the current value of the field it depends on ErrUserValueDependsOnViolation = errors.New("user value violates depends_on constraint") + // ErrInvalidCELExpression indicates a string is not a valid restricted CEL reference + ErrInvalidCELExpression = errors.New("invalid CEL expression: must match ${resourceName.outputField}") + + // ErrCELResourceNotFound indicates a CEL reference targets an unknown catalog resource + ErrCELResourceNotFound = errors.New("CEL reference resource not found in catalog item") + + // ErrCELSelfReference indicates a resource references its own output via CEL + ErrCELSelfReference = errors.New("CEL reference cannot target the same resource") + + // ErrCELServiceTypeOutputNotFound indicates the referenced output is not declared on the source service type + ErrCELServiceTypeOutputNotFound = errors.New("CEL reference output not found on service type") + + // ErrUserValueCELNotAllowed indicates user_values cannot contain CEL expressions + ErrUserValueCELNotAllowed = errors.New("user values cannot contain CEL expressions") + // ErrPlacementManagerPolicyRejected indicates the Placement Manager rejected the request due to policy (406) ErrPlacementManagerPolicyRejected = errors.New("placement manager request rejected by policy engine") diff --git a/internal/catalog/service/export_test.go b/internal/catalog/service/export_test.go index 6a877d4..f733009 100644 --- a/internal/catalog/service/export_test.go +++ b/internal/catalog/service/export_test.go @@ -17,7 +17,7 @@ func NewSpecBuilderForTest(s store.Store) *SpecBuilder { return &SpecBuilder{inner: newSpecBuilder(s)} } -// BuildResourceSpec delegates to the unexported specBuilder. -func (b *SpecBuilder) BuildResourceSpec(ctx context.Context, catalogItemId string, userValues []v1alpha1.UserValue) (map[string]any, error) { - return b.inner.BuildResourceSpec(ctx, catalogItemId, userValues) +// BuildResourceGraph delegates to the unexported specBuilder. +func (b *SpecBuilder) BuildResourceGraph(ctx context.Context, catalogItemId string, userValues []v1alpha1.UserValue) ([]ResolvedResource, error) { + return b.inner.BuildResourceGraph(ctx, catalogItemId, userValues) } diff --git a/internal/catalog/service/seed.go b/internal/catalog/service/seed.go index 5f69981..f58073d 100644 --- a/internal/catalog/service/seed.go +++ b/internal/catalog/service/seed.go @@ -94,10 +94,12 @@ func (s *service) petClinicCatalogItem() model.CatalogItem { DisplayName: "Pet Clinic", Path: "catalog-items/pet-clinic", Spec: model.CatalogItemSpec{ - ServiceType: "three-tier-app-demo", - Fields: s.petClinicFields(), + Resources: []model.CatalogResource{{ + Name: "app", + ServiceType: "three-tier-app-demo", + Fields: s.petClinicFields(), + }}, }, - SpecServiceType: "three-tier-app-demo", } } diff --git a/internal/catalog/service/seed_test.go b/internal/catalog/service/seed_test.go index 0ba2f7a..fc3f8e5 100644 --- a/internal/catalog/service/seed_test.go +++ b/internal/catalog/service/seed_test.go @@ -16,6 +16,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/service" "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) var _ = Describe("Seed", func() { @@ -119,12 +120,12 @@ var _ = Describe("Seed", func() { Expect(ci.ID).To(Equal("pet-clinic")) Expect(ci.DisplayName).To(Equal("Pet Clinic")) Expect(ci.Path).To(Equal("catalog-items/pet-clinic")) - Expect(ci.Spec.ServiceType).To(Equal("three-tier-app-demo")) - Expect(ci.Spec.Fields).To(HaveLen(5)) + Expect(ci.Spec.Resources[0].ServiceType).To(Equal("three-tier-app-demo")) + Expect(ci.Spec.Resources[0].Fields).To(HaveLen(5)) // Verify key field configs - fieldPaths := make([]string, len(ci.Spec.Fields)) - for i, f := range ci.Spec.Fields { + fieldPaths := make([]string, len(ci.Spec.Resources[0].Fields)) + for i, f := range ci.Spec.Resources[0].Fields { fieldPaths[i] = f.Path } Expect(fieldPaths).To(ContainElement("metadata.labels.region")) @@ -134,7 +135,7 @@ var _ = Describe("Seed", func() { Expect(fieldPaths).To(ContainElement("web.image")) // Verify region field uses configured values - regionField := findFieldByPath(ci.Spec.Fields, "metadata.labels.region") + regionField := findFieldByPath(ci.Spec.Resources[0].Fields, "metadata.labels.region") Expect(regionField).ToNot(BeNil()) Expect(regionField.Editable).To(BeTrue()) Expect(regionField.Default).To(BeNil()) @@ -145,7 +146,7 @@ var _ = Describe("Seed", func() { Expect(regionEnum).To(ConsistOf("region-a", "region-b")) // Verify database.engine is editable and has validation schema enum - dbEngineField := findFieldByPath(ci.Spec.Fields, "database.engine") + dbEngineField := findFieldByPath(ci.Spec.Resources[0].Fields, "database.engine") Expect(dbEngineField).ToNot(BeNil()) Expect(dbEngineField.Editable).To(BeTrue()) Expect(dbEngineField.Default).To(Equal(three_tier_app_demo.DefaultDatabaseEngine)) @@ -156,7 +157,7 @@ var _ = Describe("Seed", func() { Expect(enumVals).To(ConsistOf("postgres", "mysql")) // Verify database.version has dependsOn on database.engine and is properly constrained - dbVersionField := findFieldByPath(ci.Spec.Fields, "database.version") + dbVersionField := findFieldByPath(ci.Spec.Resources[0].Fields, "database.version") Expect(dbVersionField).ToNot(BeNil()) Expect(dbVersionField.Editable).To(BeTrue()) Expect(dbVersionField.Default).To(Equal(three_tier_app_demo.DefaultDatabaseVersion)) @@ -170,12 +171,12 @@ var _ = Describe("Seed", func() { Expect(dbVersionField.DependsOn.AllowedValues["mysql"]).To(ConsistOf("8.4", "8.3", "8")) // Verify app.image and web.image fixed defaults - appImageField := findFieldByPath(ci.Spec.Fields, "app.image") + appImageField := findFieldByPath(ci.Spec.Resources[0].Fields, "app.image") Expect(appImageField).ToNot(BeNil()) Expect(appImageField.Default).To(Equal(three_tier_app_demo.AppImage)) Expect(appImageField.Editable).To(BeFalse()) - webImageField := findFieldByPath(ci.Spec.Fields, "web.image") + webImageField := findFieldByPath(ci.Spec.Resources[0].Fields, "web.image") Expect(webImageField).ToNot(BeNil()) Expect(webImageField.Default).To(Equal(three_tier_app_demo.WebImage)) Expect(webImageField.Editable).To(BeFalse()) @@ -205,11 +206,8 @@ var _ = Describe("Seed", func() { ID: "existing-item", ApiVersion: "v1alpha1", DisplayName: "Existing", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/existing-item", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/existing-item", } _, err := dataStore.CatalogItem().Create(ctx, ci) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/catalog/service/spec_builder.go b/internal/catalog/service/spec_builder.go index f6af807..ffbd97c 100644 --- a/internal/catalog/service/spec_builder.go +++ b/internal/catalog/service/spec_builder.go @@ -5,16 +5,27 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/dcm-project/control-plane/api/catalog/v1alpha1" "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/google/uuid" "github.com/santhosh-tekuri/jsonschema/v6" ) // ServiceTypeKey is the key for the service_type field in the spec map const ServiceTypeKey = "service_type" +// ResolvedResource is a catalog resource after resolution. +type ResolvedResource struct { + Name string + ServiceType string + RequiresResources []string + Spec map[string]any + ResourceId string +} + // specBuilder resolves the reference chain and constructs the final resource spec type specBuilder struct { store store.Store @@ -25,12 +36,11 @@ func newSpecBuilder(store store.Store) *specBuilder { return &specBuilder{store: store} } -// BuildResourceSpec resolves the reference chain (CatalogItemInstance → CatalogItem → ServiceType) -// and constructs the final resource spec by: -// 1. Deep-copying the ServiceType spec as the base template -// 2. Applying CatalogItem field defaults -// 3. Applying user_values on top (with validation) -func (b *specBuilder) BuildResourceSpec(ctx context.Context, catalogItemId string, userValues []v1alpha1.UserValue) (map[string]any, error) { +// BuildResourceGraph resolves a catalog item to an effective resource graph. +// Each node includes merged specs and requires_resources edges for placement. +// Resource order matches catalog item order; DAG sort and level-by-level provisioning +// are placement's responsibility. +func (b *specBuilder) BuildResourceGraph(ctx context.Context, catalogItemId string, userValues []v1alpha1.UserValue) ([]ResolvedResource, error) { // 1. Look up CatalogItem catalogItem, err := b.store.CatalogItem().Get(ctx, catalogItemId) if err != nil { @@ -40,32 +50,76 @@ func (b *specBuilder) BuildResourceSpec(ctx context.Context, catalogItemId strin return nil, err } - // 2. Look up ServiceType by CatalogItem's service_type - serviceType, err := b.store.ServiceType().GetByServiceType(ctx, catalogItem.Spec.ServiceType) + // 2. Validate user_values against catalog item resources (paths, resources, CEL rules) + if err := validateUserValuesForCatalogItem(catalogItem.Spec, userValues); err != nil { + return nil, err + } + + // 3. Resolve each catalog resource into an effective spec node + out := make([]ResolvedResource, 0, len(catalogItem.Spec.Resources)) + resourcesByName := catalogResourcesByName(catalogItem.Spec.Resources) + for _, resource := range catalogItem.Spec.Resources { + resourceUserValues := userValuesForResource(userValues, resource.Name) + specMap, err := b.buildResourceSpecFromFields(ctx, resourcesByName, resource, resourceUserValues) + if err != nil { + return nil, fmt.Errorf("resource %s: %w", resource.Name, err) + } + out = append(out, ResolvedResource{ + Name: resource.Name, + ServiceType: resource.ServiceType, + RequiresResources: append([]string(nil), resource.RequiresResources...), + Spec: specMap, + ResourceId: uuid.New().String(), + }) + } + return out, nil +} + +// buildResourceSpecFromFields merges a catalog resource's field configuration and +// instance user values onto the service type base spec, producing the effective +// spec for one node in the resource graph. +// +// Merge order: service type spec → catalog field defaults → user values. +// CEL references (${resource.output}) in defaults are validated at merge time +// when the full resource graph is known; user_values must not contain CEL. +func (b *specBuilder) buildResourceSpecFromFields( + ctx context.Context, + resourcesByName map[string]model.CatalogResource, + resource model.CatalogResource, + userValues []v1alpha1.UserValue, +) (map[string]any, error) { + serviceTypeName := resource.ServiceType + fields := resource.Fields + + // 1. Look up ServiceType by resource's service_type + serviceType, err := b.store.ServiceType().GetByServiceType(ctx, serviceTypeName) if err != nil { - return nil, fmt.Errorf("failed to resolve service type %q: %w", catalogItem.Spec.ServiceType, err) + return nil, fmt.Errorf("failed to resolve service type %q: %w", serviceTypeName, err) } - // 3. Deep-copy ServiceType spec as base template + // 2. Deep-copy ServiceType spec as base template specMap, err := deepCopyMap(serviceType.Spec) if err != nil { return nil, fmt.Errorf("failed to copy service type spec: %w", err) } - // 3.1. Set service_type from the ServiceType instance + // 2.1. Set service_type from the ServiceType instance specMap[ServiceTypeKey] = serviceType.ServiceType - // 4. Build a lookup map of CatalogItem fields by path + // 3. Build a lookup map of catalog resource fields by path fieldsByPath := make(map[string]model.FieldConfiguration) - for _, field := range catalogItem.Spec.Fields { + for _, field := range fields { fieldsByPath[field.Path] = field } - // 5. Apply CatalogItem field defaults (validated against schema when present) - for _, field := range catalogItem.Spec.Fields { + // 4. Apply catalog field defaults (CEL, schema validation, then overlay) + for _, field := range fields { if field.Default == nil { continue } + if err := validateCELReferenceValue(ctx, b.store, resourcesByName, resource.Name, field.Path, field.Default); err != nil { + return nil, err + } if field.ValidationSchema != nil { if err := validateAgainstSchema(field.ValidationSchema, field.Default); err != nil { return nil, fmt.Errorf("%w: %s: %s", ErrFieldDefaultValidationFailed, field.Path, err.Error()) @@ -76,9 +130,13 @@ func (b *specBuilder) BuildResourceSpec(ctx context.Context, catalogItemId strin } } - // 6. Apply user_values on top (with path, editable, and schema validation) + // 5. Apply user_values on top (with path, editable, and schema validation) for _, uv := range userValues { - // Validate: user_value path must match a CatalogItem field + if isCELStringValue(uv.Value) { + return nil, fmt.Errorf("%w: %s", ErrUserValueCELNotAllowed, uv.Path) + } + + // Validate: user_value path must match a catalog resource field field, ok := fieldsByPath[uv.Path] if !ok { return nil, fmt.Errorf("%w: %s", ErrUserValuePathNotFound, uv.Path) @@ -102,7 +160,7 @@ func (b *specBuilder) BuildResourceSpec(ctx context.Context, catalogItemId strin } } - // 7. Validate depends_on constraints against final spec (all user values applied) + // 6. Validate depends_on constraints against final spec (all user values applied) for _, uv := range userValues { field := fieldsByPath[uv.Path] if field.DependsOn != nil { @@ -115,6 +173,14 @@ func (b *specBuilder) BuildResourceSpec(ctx context.Context, catalogItemId strin return specMap, nil } +func isCELStringValue(value any) bool { + str, ok := value.(string) + if !ok { + return false + } + return strings.Contains(str, "${") +} + // deepCopyMap creates a deep copy of a map[string]any by marshaling/unmarshaling JSON func deepCopyMap(src map[string]any) (map[string]any, error) { data, err := json.Marshal(src) diff --git a/internal/catalog/service/spec_builder_test.go b/internal/catalog/service/spec_builder_test.go index f6ed87b..e33e6bf 100644 --- a/internal/catalog/service/spec_builder_test.go +++ b/internal/catalog/service/spec_builder_test.go @@ -3,6 +3,7 @@ package service_test import ( "context" "errors" + "fmt" "log/slog" . "github.com/onsi/ginkgo/v2" @@ -16,8 +17,20 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/service" "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) +func buildGraphSpec(builder *service.SpecBuilder, ctx context.Context, catalogItemId string, userValues []v1alpha1.UserValue) (map[string]any, error) { + graph, err := builder.BuildResourceGraph(ctx, catalogItemId, userValues) + if err != nil { + return nil, err + } + if len(graph) == 0 { + return nil, fmt.Errorf("empty graph") + } + return graph[0].Spec, nil +} + var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { var ( ctx context.Context @@ -68,7 +81,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-chain", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(16)}, }, }, } @@ -91,7 +104,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-bad-path", UserValues: []v1alpha1.UserValue{ - {Path: "spec.network.bandwidth", Value: float64(100)}, + {Resource: testutil.DefaultResourceName, Path: "spec.network.bandwidth", Value: float64(100)}, }, }, } @@ -112,7 +125,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-not-editable", UserValues: []v1alpha1.UserValue{ - {Path: "spec.disk.size_gb", Value: float64(100)}, + {Resource: testutil.DefaultResourceName, Path: "spec.disk.size_gb", Value: float64(100)}, }, }, } @@ -142,7 +155,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-schema-fail", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(32)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(32)}, }, }, } @@ -172,7 +185,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-schema-pass", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, }, }, } @@ -205,7 +218,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-depends-fail", UserValues: []v1alpha1.UserValue{ - {Path: "spec.memory.size_gb", Value: float64(32)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(32)}, }, }, } @@ -238,7 +251,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-depends-pass", UserValues: []v1alpha1.UserValue{ - {Path: "spec.memory.size_gb", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(8)}, }, }, } @@ -271,8 +284,8 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-depends-updated", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(4)}, - {Path: "spec.memory.size_gb", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(4)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(16)}, }, }, } @@ -305,8 +318,8 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-depends-order", UserValues: []v1alpha1.UserValue{ - {Path: "spec.memory.size_gb", Value: float64(16)}, - {Path: "spec.vcpu.count", Value: float64(4)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(4)}, }, }, } @@ -338,8 +351,8 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-depends-no-key", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, - {Path: "spec.memory.size_gb", Value: float64(4)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(4)}, }, }, } @@ -351,7 +364,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { }) }) -var _ = Describe("BuildResourceSpec (direct)", func() { +var _ = Describe("BuildResourceGraph (single resource)", func() { var ( ctx context.Context db *gorm.DB @@ -388,7 +401,7 @@ var _ = Describe("BuildResourceSpec (direct)", func() { Describe("spec construction", func() { It("should return error when catalog item does not exist", func() { - _, err := builder.BuildResourceSpec(ctx, "nonexistent", nil) + _, err := buildGraphSpec(builder, ctx, "nonexistent", nil) Expect(err).To(MatchError(service.ErrCatalogItemNotFoundForInstance)) }) @@ -398,7 +411,7 @@ var _ = Describe("BuildResourceSpec (direct)", func() { {Path: "spec.memory.size_gb", Default: float64(8), Editable: false}, }) - result, err := builder.BuildResourceSpec(ctx, "ci-direct-defaults", nil) + result, err := buildGraphSpec(builder, ctx, "ci-direct-defaults", nil) Expect(err).ToNot(HaveOccurred()) vcpu := result["vcpu"].(map[string]any) @@ -414,7 +427,7 @@ var _ = Describe("BuildResourceSpec (direct)", func() { It("should set service_type in the returned spec", func() { ensureCatalogItemWithFields(ctx, str, "ci-direct-st", "vm-d", []model.FieldConfiguration{}) - result, err := builder.BuildResourceSpec(ctx, "ci-direct-st", nil) + result, err := buildGraphSpec(builder, ctx, "ci-direct-st", nil) Expect(err).ToNot(HaveOccurred()) Expect(result["service_type"]).To(Equal("vm-d")) }) @@ -426,10 +439,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(16)}, } - result, err := builder.BuildResourceSpec(ctx, "ci-direct-override", userValues) + result, err := buildGraphSpec(builder, ctx, "ci-direct-override", userValues) Expect(err).ToNot(HaveOccurred()) vcpu := result["vcpu"].(map[string]any) @@ -446,7 +459,7 @@ var _ = Describe("BuildResourceSpec (direct)", func() { {Path: "spec.vcpu.count", Default: float64(4), Editable: true}, }) - result, err := builder.BuildResourceSpec(ctx, "ci-direct-preserve", nil) + result, err := buildGraphSpec(builder, ctx, "ci-direct-preserve", nil) Expect(err).ToNot(HaveOccurred()) // disk and memory should remain at ServiceType base values @@ -464,10 +477,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.network.bandwidth", Value: float64(100)}, + {Resource: testutil.DefaultResourceName, Path: "spec.network.bandwidth", Value: float64(100)}, } - _, err := builder.BuildResourceSpec(ctx, "ci-direct-badpath", userValues) + _, err := buildGraphSpec(builder, ctx, "ci-direct-badpath", userValues) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("user value path not found")) }) @@ -478,10 +491,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.disk.size_gb", Value: float64(100)}, + {Resource: testutil.DefaultResourceName, Path: "spec.disk.size_gb", Value: float64(100)}, } - _, err := builder.BuildResourceSpec(ctx, "ci-direct-noedit", userValues) + _, err := buildGraphSpec(builder, ctx, "ci-direct-noedit", userValues) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("not editable")) }) @@ -500,7 +513,7 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }, }) - _, err := builder.BuildResourceSpec(ctx, "ci-direct-default-schemafail", nil) + _, err := buildGraphSpec(builder, ctx, "ci-direct-default-schemafail", nil) Expect(err).To(HaveOccurred()) Expect(errors.Is(err, service.ErrFieldDefaultValidationFailed)).To(BeTrue()) Expect(err.Error()).To(ContainSubstring("spec.vcpu.count")) @@ -521,10 +534,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(32)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(32)}, } - _, err := builder.BuildResourceSpec(ctx, "ci-direct-schemafail", userValues) + _, err := buildGraphSpec(builder, ctx, "ci-direct-schemafail", userValues) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("validation failed")) }) @@ -544,10 +557,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, } - result, err := builder.BuildResourceSpec(ctx, "ci-direct-schemapass", userValues) + result, err := buildGraphSpec(builder, ctx, "ci-direct-schemapass", userValues) Expect(err).ToNot(HaveOccurred()) vcpu := result["vcpu"].(map[string]any) @@ -571,10 +584,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.memory.size_gb", Value: float64(32)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(32)}, } - _, err := builder.BuildResourceSpec(ctx, "ci-direct-depfail", userValues) + _, err := buildGraphSpec(builder, ctx, "ci-direct-depfail", userValues) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("depends_on")) }) @@ -596,10 +609,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.memory.size_gb", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(8)}, } - result, err := builder.BuildResourceSpec(ctx, "ci-direct-deppass", userValues) + result, err := buildGraphSpec(builder, ctx, "ci-direct-deppass", userValues) Expect(err).ToNot(HaveOccurred()) memory := result["memory"].(map[string]any) @@ -623,11 +636,11 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(4)}, - {Path: "spec.memory.size_gb", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(4)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(16)}, } - result, err := builder.BuildResourceSpec(ctx, "ci-direct-depsrc", userValues) + result, err := buildGraphSpec(builder, ctx, "ci-direct-depsrc", userValues) Expect(err).ToNot(HaveOccurred()) vcpu := result["vcpu"].(map[string]any) @@ -653,11 +666,11 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, - {Path: "spec.memory.size_gb", Value: float64(4)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(4)}, } - _, err := builder.BuildResourceSpec(ctx, "ci-direct-depnokey", userValues) + _, err := buildGraphSpec(builder, ctx, "ci-direct-depnokey", userValues) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("no allowed values defined")) }) @@ -670,12 +683,12 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, - {Path: "spec.memory.size_gb", Value: float64(16)}, - {Path: "spec.disk.size_gb", Value: float64(200)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.disk.size_gb", Value: float64(200)}, } - result, err := builder.BuildResourceSpec(ctx, "ci-direct-multi", userValues) + result, err := buildGraphSpec(builder, ctx, "ci-direct-multi", userValues) Expect(err).ToNot(HaveOccurred()) Expect(result["service_type"]).To(Equal("vm-d")) @@ -685,3 +698,83 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) }) }) + +var _ = Describe("BuildResourceGraph (multi-resource)", func() { + var ( + ctx context.Context + db *gorm.DB + str store.Store + builder *service.SpecBuilder + ) + + BeforeEach(func() { + ctx = context.Background() + var err error + db, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Discard, + }) + Expect(err).ToNot(HaveOccurred()) + err = db.Exec("PRAGMA foreign_keys = ON").Error + Expect(err).ToNot(HaveOccurred()) + err = db.AutoMigrate(&model.ServiceType{}, &model.CatalogItem{}, &model.CatalogItemInstance{}) + Expect(err).ToNot(HaveOccurred()) + str = store.NewStore(db, slog.Default()) + builder = service.NewSpecBuilderForTest(str) + + ensureServiceTypeWithSpec(ctx, str, "db-st", "database", map[string]any{ + "engine": "postgres", + "version": "14", + }) + ensureServiceTypeWithSpec(ctx, str, "ctr-st", "container", map[string]any{ + "image": map[string]any{"reference": "nginx"}, + }) + }) + + AfterEach(func() { + if str != nil { + Expect(str.Close()).To(Succeed()) + } + }) + + It("should resolve multi-resource catalog item with per-resource user values", func() { + ci := model.CatalogItem{ + ID: "dev-app", + ApiVersion: "v1alpha1", + DisplayName: "Dev App", + Spec: model.CatalogItemSpec{ + Resources: []model.CatalogResource{ + { + Name: "ordersDb", + ServiceType: "database", + Fields: []model.FieldConfiguration{ + {Path: "engine", Default: "postgres", Editable: true}, + {Path: "version", Default: "16", Editable: true}, + }, + }, + { + Name: "app", + ServiceType: "container", + RequiresResources: []string{"ordersDb"}, + Fields: []model.FieldConfiguration{ + {Path: "image.reference", Default: "registry.example.com/app:1.0"}, + }, + }, + }, + }, + Path: "catalog-items/dev-app", + } + _, err := str.CatalogItem().Create(ctx, ci) + Expect(err).ToNot(HaveOccurred()) + + resource := "ordersDb" + graph, err := builder.BuildResourceGraph(ctx, "dev-app", []v1alpha1.UserValue{ + {Resource: resource, Path: "version", Value: "17"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(graph).To(HaveLen(2)) + Expect(graph[0].Name).To(Equal("ordersDb")) + Expect(graph[0].Spec["version"]).To(Equal("17")) + Expect(graph[1].Name).To(Equal("app")) + Expect(graph[1].RequiresResources).To(Equal([]string{"ordersDb"})) + }) +}) diff --git a/internal/catalog/store/catalog_item.go b/internal/catalog/store/catalog_item.go index 488d912..d4a2d89 100644 --- a/internal/catalog/store/catalog_item.go +++ b/internal/catalog/store/catalog_item.go @@ -76,7 +76,7 @@ func (s *catalogItemStore) List(ctx context.Context, opts *CatalogItemListOption query = query.Order("id ASC").Limit(pageSize + 1).Offset(offset) if opts != nil && opts.ServiceType != nil && *opts.ServiceType != "" { - query = query.Where("spec_service_type = ?", *opts.ServiceType) + query = applyCatalogItemServiceTypeFilter(query, *opts.ServiceType) } if err := query.Find(&catalogItems).Error; err != nil { @@ -100,7 +100,6 @@ func (s *catalogItemStore) List(ctx context.Context, opts *CatalogItemListOption // Create creates a new catalog item func (s *catalogItemStore) Create(ctx context.Context, catalogItem model.CatalogItem) (*model.CatalogItem, error) { - catalogItem.SpecServiceType = catalogItem.Spec.ServiceType if err := s.db.WithContext(ctx).Clauses(clause.Returning{}).Create(&catalogItem).Error; err != nil { return nil, s.mapConstraintError(ctx, err, catalogItem) } @@ -115,18 +114,6 @@ func (s *catalogItemStore) mapConstraintError(ctx context.Context, err error, at errStr := strings.ToLower(err.Error()) - // Check for foreign key violation first (before checking for generic constraint failed) - if strings.Contains(errStr, "foreign key") { - // Verify which constraint failed by checking if service type exists - var st model.ServiceType - if err := s.db.WithContext(ctx).Where("service_type = ?", attempted.SpecServiceType).First(&st).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrServiceTypeNotFound - } - } - return err - } - // Handle unique constraint violations if errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(errStr, "unique") || @@ -158,12 +145,9 @@ func (s *catalogItemStore) Get(ctx context.Context, id string) (*model.CatalogIt // Update updates a catalog item (only mutable fields) func (s *catalogItemStore) Update(ctx context.Context, catalogItem *model.CatalogItem) error { - // Extract service type from spec for denormalized field - catalogItem.SpecServiceType = catalogItem.Spec.ServiceType - result := s.db.WithContext(ctx).Model(&model.CatalogItem{}). Where("id = ?", catalogItem.ID). - Select("display_name", "spec", "spec_service_type"). + Select("display_name", "spec"). Updates(catalogItem) if result.Error != nil { diff --git a/internal/catalog/store/catalog_item_filter.go b/internal/catalog/store/catalog_item_filter.go new file mode 100644 index 0000000..dbbeb4d --- /dev/null +++ b/internal/catalog/store/catalog_item_filter.go @@ -0,0 +1,22 @@ +package store + +import "gorm.io/gorm" + +// applyCatalogItemServiceTypeFilter restricts results to catalog items whose spec.resources +// includes at least one entry with the given service_type. +func applyCatalogItemServiceTypeFilter(query *gorm.DB, serviceType string) *gorm.DB { + switch query.Dialector.Name() { + case "postgres": + return query.Where(`EXISTS ( + SELECT 1 FROM jsonb_array_elements(spec->'resources') AS resource + WHERE resource->>'service_type' = ? + )`, serviceType) + case "sqlite": + return query.Where(`EXISTS ( + SELECT 1 FROM json_each(spec, '$.resources') AS resource + WHERE json_extract(resource.value, '$.service_type') = ? + )`, serviceType) + default: + return query + } +} diff --git a/internal/catalog/store/catalog_item_instance.go b/internal/catalog/store/catalog_item_instance.go index 2c08478..eee331c 100644 --- a/internal/catalog/store/catalog_item_instance.go +++ b/internal/catalog/store/catalog_item_instance.go @@ -177,28 +177,31 @@ func (s *catalogItemInstanceStore) Update(ctx context.Context, catalogItemInstan return catalogItemInstance, nil } -// UpdateResourceID atomically updates resource_id only when it still matches expectedResourceID. -// Returns ErrCatalogItemInstanceConflict if the row exists but resource_id has changed (concurrent modification). +// TODO: Update spec.resource_ids for multi resources +// UpdateResourceID updates spec.resource_ids[0] only when it still matches expectedResourceID. +// Returns ErrCatalogItemInstanceConflict if the row exists but the first resource ID has changed. func (s *catalogItemInstanceStore) UpdateResourceID(ctx context.Context, id string, expectedResourceID string, newResourceID string) (*model.CatalogItemInstance, error) { - result := s.db.WithContext(ctx).Model(&model.CatalogItemInstance{}). - Where("id = ? AND resource_id = ?", id, expectedResourceID). - Update("resource_id", newResourceID) + inst, err := s.Get(ctx, id) + if err != nil { + if errors.Is(err, ErrCatalogItemInstanceNotFound) { + return nil, ErrCatalogItemInstanceNotFound + } + return nil, fmt.Errorf("failed to get catalog item instance: %w", err) + } + if len(inst.Spec.ResourceIDs) == 0 || inst.Spec.ResourceIDs[0] != expectedResourceID { + return nil, ErrCatalogItemInstanceConflict + } + inst.Spec.ResourceIDs[0] = newResourceID + result := s.db.WithContext(ctx).Model(&inst).Where("id = ?", id).Select("spec").Updates(&inst) if result.Error != nil { return nil, fmt.Errorf("failed to update resource ID: %w", result.Error) } if result.RowsAffected == 0 { - _, err := s.Get(ctx, id) - if errors.Is(err, ErrCatalogItemInstanceNotFound) { - return nil, ErrCatalogItemInstanceNotFound - } - if err != nil { - return nil, fmt.Errorf("failed to check instance existence: %w", err) - } - return nil, ErrCatalogItemInstanceConflict + return nil, ErrCatalogItemInstanceNotFound } - return s.Get(ctx, id) + return inst, nil } // Delete deletes a catalog item by ID diff --git a/internal/catalog/store/catalog_item_instance_test.go b/internal/catalog/store/catalog_item_instance_test.go index fa96246..147e5c2 100644 --- a/internal/catalog/store/catalog_item_instance_test.go +++ b/internal/catalog/store/catalog_item_instance_test.go @@ -14,6 +14,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) var _ = Describe("CatalogItemInstance Store", func() { @@ -65,11 +66,8 @@ var _ = Describe("CatalogItemInstance Store", func() { ID: id, ApiVersion: "v1alpha1", DisplayName: fmt.Sprintf("Test %s", id), - Spec: model.CatalogItemSpec{ - ServiceType: serviceType, - Fields: []model.FieldConfiguration{}, - }, - Path: fmt.Sprintf("catalog-items/%s", id), + Spec: testutil.ModelCatalogSpec(serviceType, []model.FieldConfiguration{}), + Path: fmt.Sprintf("catalog-items/%s", id), } _, err := catalogItemStore.Create(context.Background(), ci) Expect(err).ToNot(HaveOccurred()) @@ -115,7 +113,7 @@ var _ = Describe("CatalogItemInstance Store", func() { Expect(retrieved.DisplayName).To(Equal(created.DisplayName)) Expect(retrieved.Spec.CatalogItemId).To(Equal(created.Spec.CatalogItemId)) Expect(retrieved.SpecCatalogItemId).To(Equal(created.SpecCatalogItemId)) - Expect(retrieved.ResourceID).To(Equal(created.ResourceID)) + Expect(retrieved.Spec.ResourceIDs).To(Equal(created.Spec.ResourceIDs)) }) It("should return error when creating duplicate ID", func() { @@ -217,7 +215,7 @@ var _ = Describe("CatalogItemInstance Store", func() { Expect(err).ToNot(HaveOccurred()) Expect(retrieved.ID).To(Equal(created.ID)) Expect(retrieved.Spec.CatalogItemId).To(Equal("small-vm-get")) - Expect(retrieved.ResourceID).To(Equal(created.ResourceID)) + Expect(retrieved.Spec.ResourceIDs).To(Equal(created.Spec.ResourceIDs)) }) It("should return error for non-existent catalog item instance", func() { @@ -260,21 +258,21 @@ var _ = Describe("CatalogItemInstance Store", func() { }) }) - Describe("UpdateResourceID", func() { - It("should update resource_id when expected value matches", func() { + Describe("UpdateResourceIDs", func() { + It("should update resource_ids when expected value matches", func() { createTestServiceType("vm-st-upd", "vm") createTestCatalogItem("small-vm-upd", "vm") cii := model.CatalogItemInstance{ ID: "upd-res-cii", ApiVersion: "v1alpha1", - DisplayName: "Update ResourceID", + DisplayName: "Update ResourceIDs", Spec: model.CatalogItemInstanceSpec{ CatalogItemId: "small-vm-upd", UserValues: []model.UserValue{}, + ResourceIDs: []string{"old-resource-id"}, }, - ResourceID: "old-resource-id", - Path: "catalog-item-instances/upd-res-cii", + Path: "catalog-item-instances/upd-res-cii", } _, err := catalogItemInstanceStore.Create(context.Background(), cii) @@ -283,13 +281,13 @@ var _ = Describe("CatalogItemInstance Store", func() { updated, err := catalogItemInstanceStore.UpdateResourceID(context.Background(), "upd-res-cii", "old-resource-id", "new-resource-id") Expect(err).ToNot(HaveOccurred()) Expect(updated).ToNot(BeNil()) - Expect(updated.ResourceID).To(Equal("new-resource-id")) + Expect(updated.Spec.ResourceIDs[0]).To(Equal("new-resource-id")) // Verify persisted retrieved, err := catalogItemInstanceStore.Get(context.Background(), "upd-res-cii") Expect(err).ToNot(HaveOccurred()) - Expect(retrieved.ResourceID).To(Equal("new-resource-id")) - Expect(retrieved.DisplayName).To(Equal("Update ResourceID")) + Expect(retrieved.Spec.ResourceIDs[0]).To(Equal("new-resource-id")) + Expect(retrieved.DisplayName).To(Equal("Update ResourceIDs")) }) It("should return conflict when expected resource_id does not match", func() { @@ -303,9 +301,9 @@ var _ = Describe("CatalogItemInstance Store", func() { Spec: model.CatalogItemInstanceSpec{ CatalogItemId: "small-vm-cas", UserValues: []model.UserValue{}, + ResourceIDs: []string{"current-resource-id"}, }, - ResourceID: "current-resource-id", - Path: "catalog-item-instances/cas-conflict-cii", + Path: "catalog-item-instances/cas-conflict-cii", } _, err := catalogItemInstanceStore.Create(context.Background(), cii) @@ -314,10 +312,10 @@ var _ = Describe("CatalogItemInstance Store", func() { _, err = catalogItemInstanceStore.UpdateResourceID(context.Background(), "cas-conflict-cii", "stale-resource-id", "new-resource-id") Expect(err).To(Equal(store.ErrCatalogItemInstanceConflict)) - // Verify resource_id unchanged + // Verify resource_ids unchanged retrieved, err := catalogItemInstanceStore.Get(context.Background(), "cas-conflict-cii") Expect(err).ToNot(HaveOccurred()) - Expect(retrieved.ResourceID).To(Equal("current-resource-id")) + Expect(retrieved.Spec.ResourceIDs).To(Equal([]string{"current-resource-id"})) }) It("should return not found for non-existent instance", func() { diff --git a/internal/catalog/store/catalog_item_test.go b/internal/catalog/store/catalog_item_test.go index 66dc92b..7463ba7 100644 --- a/internal/catalog/store/catalog_item_test.go +++ b/internal/catalog/store/catalog_item_test.go @@ -14,6 +14,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) var _ = Describe("CatalogItem Store", func() { @@ -72,16 +73,13 @@ var _ = Describe("CatalogItem Store", func() { ID: "small-vm", ApiVersion: "v1alpha1", DisplayName: "Small VM", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{ - { - Path: "spec.vcpu.count", - Editable: false, - Default: 2, - }, + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{ + { + Path: "spec.vcpu.count", + Editable: false, + Default: 2, }, - }, + }), Path: "catalog-items/small-vm", } @@ -93,8 +91,7 @@ var _ = Describe("CatalogItem Store", func() { Expect(err).ToNot(HaveOccurred()) Expect(retrieved.ID).To(Equal("small-vm")) Expect(retrieved.DisplayName).To(Equal("Small VM")) - Expect(retrieved.Spec.ServiceType).To(Equal("vm")) - Expect(retrieved.SpecServiceType).To(Equal("vm")) + Expect(retrieved.Spec.Resources[0].ServiceType).To(Equal("vm")) }) It("should return error when creating duplicate ID", func() { @@ -105,11 +102,8 @@ var _ = Describe("CatalogItem Store", func() { ID: "duplicate-ci", ApiVersion: "v1alpha1", DisplayName: "Original", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/duplicate-ci", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/duplicate-ci", } _, err := catalogItemStore.Create(context.Background(), *ci) @@ -120,33 +114,14 @@ var _ = Describe("CatalogItem Store", func() { ID: "duplicate-ci", ApiVersion: "v1alpha1", DisplayName: "Duplicate", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/duplicate-ci", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/duplicate-ci", } _, err = catalogItemStore.Create(context.Background(), ci2) Expect(err).To(Equal(store.ErrCatalogItemIDTaken)) }) - It("should return error when creating with non-existent service type", func() { - ci := &model.CatalogItem{ - ID: "invalid-st-ci", - ApiVersion: "v1alpha1", - DisplayName: "Invalid Service Type", - Spec: model.CatalogItemSpec{ - ServiceType: "non-existent-service-type", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/invalid-st-ci", - } - - _, err := catalogItemStore.Create(context.Background(), *ci) - Expect(err).To(Equal(store.ErrServiceTypeNotFound)) - }) - It("should create catalog item with valid service type", func() { // Create prerequisite service type createTestServiceType("valid-st", "valid-service") @@ -155,11 +130,8 @@ var _ = Describe("CatalogItem Store", func() { ID: "valid-ci", ApiVersion: "v1alpha1", DisplayName: "Valid Catalog Item", - Spec: model.CatalogItemSpec{ - ServiceType: "valid-service", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/valid-ci", + Spec: testutil.ModelCatalogSpec("valid-service", []model.FieldConfiguration{}), + Path: "catalog-items/valid-ci", } _, err := catalogItemStore.Create(context.Background(), *ci) @@ -176,12 +148,9 @@ var _ = Describe("CatalogItem Store", func() { ID: "get-test-ci", ApiVersion: "v1alpha1", DisplayName: "Test Item", - Spec: model.CatalogItemSpec{ - ServiceType: "database", - Fields: []model.FieldConfiguration{ - {Path: "spec.engine", Default: "postgres"}, - }, - }, + Spec: testutil.ModelCatalogSpec("database", []model.FieldConfiguration{ + {Path: "spec.engine", Default: "postgres"}, + }), Path: "catalog-items/get-test-ci", } @@ -191,7 +160,7 @@ var _ = Describe("CatalogItem Store", func() { retrieved, err := catalogItemStore.Get(context.Background(), "get-test-ci") Expect(err).ToNot(HaveOccurred()) Expect(retrieved.ID).To(Equal("get-test-ci")) - Expect(retrieved.Spec.ServiceType).To(Equal("database")) + Expect(retrieved.Spec.Resources[0].ServiceType).To(Equal("database")) }) It("should return error for non-existent catalog item", func() { @@ -209,12 +178,9 @@ var _ = Describe("CatalogItem Store", func() { ID: "update-test", ApiVersion: "v1alpha1", DisplayName: "Original Name", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{ - {Path: "spec.vcpu.count", Default: 2}, - }, - }, + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{ + {Path: "spec.vcpu.count", Default: 2}, + }), Path: "catalog-items/update-test", } @@ -224,7 +190,7 @@ var _ = Describe("CatalogItem Store", func() { // Update mutable fields ci.DisplayName = "Updated Name" - ci.Spec.Fields = append(ci.Spec.Fields, model.FieldConfiguration{ + ci.Spec.Resources[0].Fields = append(ci.Spec.Resources[0].Fields, model.FieldConfiguration{ Path: "spec.memory.size_gb", Default: 8, }) @@ -236,7 +202,7 @@ var _ = Describe("CatalogItem Store", func() { retrieved, err := catalogItemStore.Get(context.Background(), "update-test") Expect(err).ToNot(HaveOccurred()) Expect(retrieved.DisplayName).To(Equal("Updated Name")) - Expect(retrieved.Spec.Fields).To(HaveLen(2)) + Expect(retrieved.Spec.Resources[0].Fields).To(HaveLen(2)) }) It("should not update immutable fields", func() { @@ -249,11 +215,8 @@ var _ = Describe("CatalogItem Store", func() { ID: "immutable-update-test", ApiVersion: originalApiVersion, DisplayName: "Original Name", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: originalPath, + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: originalPath, } created, err := catalogItemStore.Create(context.Background(), *ci) @@ -277,62 +240,25 @@ var _ = Describe("CatalogItem Store", func() { }) It("should return error when updating non-existent catalog item", func() { - // Create prerequisite service type - createTestServiceType("vm-st-nonexist", "vm") - ci := &model.CatalogItem{ ID: "non-existent", DisplayName: "Updated", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), } err := catalogItemStore.Update(context.Background(), ci) Expect(err).To(Equal(store.ErrCatalogItemNotFound)) }) - - It("should return error when updating with non-existent service type", func() { - // Create prerequisite service types - createTestServiceType("vm-st-orig", "vm") - - ci := &model.CatalogItem{ - ID: "update-invalid-st", - ApiVersion: "v1alpha1", - DisplayName: "Original", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/update-invalid-st", - } - - created, err := catalogItemStore.Create(context.Background(), *ci) - Expect(err).ToNot(HaveOccurred()) - ci = created - - // Try to update with non-existent service type - ci.Spec.ServiceType = "non-existent-service-type" - err = catalogItemStore.Update(context.Background(), ci) - Expect(err).To(Equal(store.ErrServiceTypeNotFound)) - }) }) Describe("Delete", func() { It("should delete an existing catalog item", func() { - // Create prerequisite service type - createTestServiceType("vm-st-del", "vm") - ci := &model.CatalogItem{ ID: "delete-test", ApiVersion: "v1alpha1", DisplayName: "To Delete", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/delete-test", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/delete-test", } _, err := catalogItemStore.Create(context.Background(), *ci) @@ -373,11 +299,8 @@ var _ = Describe("CatalogItem Store", func() { ID: fmt.Sprintf("ci-%d", i), ApiVersion: "v1alpha1", DisplayName: fmt.Sprintf("Item %d", i), - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: fmt.Sprintf("catalog-items/ci-%d", i), + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: fmt.Sprintf("catalog-items/ci-%d", i), } time.Sleep(time.Millisecond) _, err := catalogItemStore.Create(context.Background(), ci) @@ -400,11 +323,8 @@ var _ = Describe("CatalogItem Store", func() { ID: "vm-item", ApiVersion: "v1alpha1", DisplayName: "VM Item", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/vm-item", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/vm-item", } _, err := catalogItemStore.Create(context.Background(), ci1) Expect(err).ToNot(HaveOccurred()) @@ -413,11 +333,8 @@ var _ = Describe("CatalogItem Store", func() { ID: "db-item", ApiVersion: "v1alpha1", DisplayName: "DB Item", - Spec: model.CatalogItemSpec{ - ServiceType: "database", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/db-item", + Spec: testutil.ModelCatalogSpec("database", []model.FieldConfiguration{}), + Path: "catalog-items/db-item", } _, err = catalogItemStore.Create(context.Background(), ci2) Expect(err).ToNot(HaveOccurred()) @@ -427,14 +344,14 @@ var _ = Describe("CatalogItem Store", func() { result, err := catalogItemStore.List(context.Background(), &store.CatalogItemListOptions{PageSize: 100, ServiceType: &serviceTypeVM}) Expect(err).ToNot(HaveOccurred()) Expect(result.CatalogItems).To(HaveLen(1)) - Expect(result.CatalogItems[0].Spec.ServiceType).To(Equal("vm")) + Expect(result.CatalogItems[0].Spec.Resources[0].ServiceType).To(Equal("vm")) // Filter for database service type serviceTypeDB := "database" result, err = catalogItemStore.List(context.Background(), &store.CatalogItemListOptions{PageSize: 100, ServiceType: &serviceTypeDB}) Expect(err).ToNot(HaveOccurred()) Expect(result.CatalogItems).To(HaveLen(1)) - Expect(result.CatalogItems[0].Spec.ServiceType).To(Equal("database")) + Expect(result.CatalogItems[0].Spec.Resources[0].ServiceType).To(Equal("database")) // Filter for non-existent service type serviceTypeNonExistent := "non-existent" @@ -443,6 +360,42 @@ var _ = Describe("CatalogItem Store", func() { Expect(result.CatalogItems).To(BeEmpty()) }) + It("should filter multi-resource items when any resource matches", func() { + ci1 := model.CatalogItem{ + ID: "vm-only", + ApiVersion: "v1alpha1", + DisplayName: "VM Only", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/vm-only", + } + _, err := catalogItemStore.Create(context.Background(), ci1) + Expect(err).ToNot(HaveOccurred()) + + ci2 := model.CatalogItem{ + ID: "dev-app", + ApiVersion: "v1alpha1", + DisplayName: "Dev App", + Spec: model.CatalogItemSpec{ + Resources: []model.CatalogResource{ + {Name: "ordersDb", ServiceType: "database"}, + {Name: "app", ServiceType: "container"}, + }, + }, + Path: "catalog-items/dev-app", + } + _, err = catalogItemStore.Create(context.Background(), ci2) + Expect(err).ToNot(HaveOccurred()) + + containerFilter := "container" + result, err := catalogItemStore.List(context.Background(), &store.CatalogItemListOptions{ + PageSize: 100, + ServiceType: &containerFilter, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result.CatalogItems).To(HaveLen(1)) + Expect(result.CatalogItems[0].ID).To(Equal("dev-app")) + }) + It("should handle pagination correctly", func() { // Create prerequisite service type createTestServiceType("vm-st-page", "vm") @@ -453,11 +406,8 @@ var _ = Describe("CatalogItem Store", func() { ID: fmt.Sprintf("page-ci-%d", i), ApiVersion: "v1alpha1", DisplayName: fmt.Sprintf("Item %d", i), - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: fmt.Sprintf("catalog-items/page-ci-%d", i), + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: fmt.Sprintf("catalog-items/page-ci-%d", i), } time.Sleep(time.Millisecond) _, err := catalogItemStore.Create(context.Background(), ci) diff --git a/internal/catalog/store/integration_test.go b/internal/catalog/store/integration_test.go index 0be5b69..817f4e2 100644 --- a/internal/catalog/store/integration_test.go +++ b/internal/catalog/store/integration_test.go @@ -12,6 +12,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) var _ = Describe("Foreign Key Constraint Integration Tests", func() { @@ -69,11 +70,8 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { ID: "small-vm", ApiVersion: "v1alpha1", DisplayName: "Small VM", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/small-vm", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/small-vm", } _, err = catalogItemStore.Create(ctx, ci) Expect(err).ToNot(HaveOccurred()) @@ -99,7 +97,7 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { retrievedCI, err := catalogItemStore.Get(ctx, "small-vm") Expect(err).ToNot(HaveOccurred()) - Expect(retrievedCI.Spec.ServiceType).To(Equal("vm")) + Expect(retrievedCI.Spec.Resources[0].ServiceType).To(Equal("vm")) retrievedCII, err := catalogItemInstanceStore.Get(ctx, "my-vm") Expect(err).ToNot(HaveOccurred()) @@ -108,23 +106,6 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { }) Describe("Foreign Key Violation Detection", func() { - It("should prevent creating CatalogItem with non-existent ServiceType", func() { - ctx := context.Background() - - ci := model.CatalogItem{ - ID: "invalid-ci", - ApiVersion: "v1alpha1", - DisplayName: "Invalid Item", - Spec: model.CatalogItemSpec{ - ServiceType: "non-existent", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/invalid-ci", - } - _, err := catalogItemStore.Create(ctx, ci) - Expect(err).To(Equal(store.ErrServiceTypeNotFound)) - }) - It("should prevent creating CatalogItemInstance with non-existent CatalogItem", func() { ctx := context.Background() @@ -142,39 +123,6 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { Expect(err).To(Equal(store.ErrCatalogItemNotFoundRef)) }) - It("should prevent updating CatalogItem to non-existent ServiceType", func() { - ctx := context.Background() - - // Create valid hierarchy first - st := model.ServiceType{ - ID: "vm-st", - ApiVersion: "v1alpha1", - ServiceType: "vm", - Spec: map[string]any{}, - Path: "service-types/vm-st", - } - _, err := serviceTypeStore.Create(ctx, st) - Expect(err).ToNot(HaveOccurred()) - - ci := model.CatalogItem{ - ID: "test-ci", - ApiVersion: "v1alpha1", - DisplayName: "Test Item", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/test-ci", - } - created, err := catalogItemStore.Create(ctx, ci) - Expect(err).ToNot(HaveOccurred()) - - // Try to update to non-existent service type - created.Spec.ServiceType = "non-existent" - err = catalogItemStore.Update(ctx, created) - Expect(err).To(Equal(store.ErrServiceTypeNotFound)) - }) - It("should prevent updating CatalogItemInstance to non-existent CatalogItem", func() { ctx := context.Background() @@ -193,11 +141,8 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { ID: "test-ci-update", ApiVersion: "v1alpha1", DisplayName: "Test Item", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/test-ci-update", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/test-ci-update", } _, err = catalogItemStore.Create(ctx, ci) Expect(err).ToNot(HaveOccurred()) @@ -241,11 +186,8 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { ID: "test-ci-del", ApiVersion: "v1alpha1", DisplayName: "Test Item", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/test-ci-del", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/test-ci-del", } _, err = catalogItemStore.Create(ctx, ci) Expect(err).ToNot(HaveOccurred()) @@ -294,11 +236,8 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { ID: "test-ci-del-no-inst", ApiVersion: "v1alpha1", DisplayName: "Test Item", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/test-ci-del-no-inst", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/test-ci-del-no-inst", } _, err = catalogItemStore.Create(ctx, ci) Expect(err).ToNot(HaveOccurred()) @@ -317,20 +256,6 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { It("should return correct error for each violation type", func() { ctx := context.Background() - // Test ErrServiceTypeNotFound - ci := model.CatalogItem{ - ID: "err-test-1", - ApiVersion: "v1alpha1", - DisplayName: "Error Test 1", - Spec: model.CatalogItemSpec{ - ServiceType: "missing-st", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/err-test-1", - } - _, err := catalogItemStore.Create(ctx, ci) - Expect(err).To(Equal(store.ErrServiceTypeNotFound)) - // Test ErrCatalogItemNotFoundRef cii := model.CatalogItemInstance{ ID: "err-test-2", @@ -342,7 +267,7 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { }, Path: "catalog-item-instances/err-test-2", } - _, err = catalogItemInstanceStore.Create(ctx, cii) + _, err := catalogItemInstanceStore.Create(ctx, cii) Expect(err).To(Equal(store.ErrCatalogItemNotFoundRef)) // Test ErrCatalogItemHasInstances @@ -361,11 +286,8 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { ID: "err-test-ci", ApiVersion: "v1alpha1", DisplayName: "Error Test CI", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/err-test-ci", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/err-test-ci", } _, err = catalogItemStore.Create(ctx, ci2) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/catalog/store/model/catalog_item.go b/internal/catalog/store/model/catalog_item.go index b2cc81f..2b9d75a 100644 --- a/internal/catalog/store/model/catalog_item.go +++ b/internal/catalog/store/model/catalog_item.go @@ -14,19 +14,32 @@ type CatalogItem struct { Path string `gorm:"column:path;not null"` CreateTime time.Time `gorm:"column:create_time;autoCreateTime"` UpdateTime time.Time `gorm:"column:update_time;autoUpdateTime"` - - // Indexed field for filtering - SpecServiceType string `gorm:"column:spec_service_type;not null;index"` - ServiceTypeRef *ServiceType `gorm:"foreignKey:SpecServiceType;references:ServiceType;constraint:OnDelete:RESTRICT"` } // CatalogItemList is a slice of CatalogItem for list results type CatalogItemList []CatalogItem -// CatalogItemSpec represents the spec field of a catalog item +// CatalogItemSpec represents the spec field of a catalog item. type CatalogItemSpec struct { - ServiceType string `json:"service_type"` - Fields []FieldConfiguration `json:"fields"` + Resources []CatalogResource `json:"resources"` +} + +// HasResourceServiceType reports whether any resource uses the given service type. +func (s CatalogItemSpec) HasResourceServiceType(serviceType string) bool { + for _, r := range s.Resources { + if r.ServiceType == serviceType { + return true + } + } + return false +} + +// CatalogResource is a named resource within a catalog item. +type CatalogResource struct { + Name string `json:"name"` + ServiceType string `json:"service_type"` + RequiresResources []string `json:"requires_resources,omitempty"` + Fields []FieldConfiguration `json:"fields,omitempty"` } // DependsOn defines conditional default based on another field's value diff --git a/internal/catalog/store/model/catalog_item_instance.go b/internal/catalog/store/model/catalog_item_instance.go index 16302c6..50330c1 100644 --- a/internal/catalog/store/model/catalog_item_instance.go +++ b/internal/catalog/store/model/catalog_item_instance.go @@ -10,7 +10,6 @@ type CatalogItemInstance struct { ApiVersion string `gorm:"column:api_version;not null"` DisplayName string `gorm:"column:display_name;not null"` Spec CatalogItemInstanceSpec `gorm:"column:spec;type:jsonb;not null;serializer:json"` - ResourceID string `gorm:"column:resource_id"` Path string `gorm:"column:path;not null"` CreateTime time.Time `gorm:"column:create_time;autoCreateTime"` UpdateTime time.Time `gorm:"column:update_time;autoUpdateTime"` @@ -27,10 +26,13 @@ type CatalogItemInstanceList []CatalogItemInstance type CatalogItemInstanceSpec struct { CatalogItemId string `json:"catalog_item_id"` UserValues []UserValue `json:"user_values"` + // ResourceIDs stores placement resource IDs for multi-resource instances (all nodes). + ResourceIDs []string `json:"resource_ids,omitempty"` } // UserValue represents a user-provided value for a field type UserValue struct { - Path string `json:"path"` - Value any `json:"value"` + Resource string `json:"resource,omitempty"` + Path string `json:"path"` + Value any `json:"value"` } diff --git a/internal/catalog/testutil/catalog_spec.go b/internal/catalog/testutil/catalog_spec.go new file mode 100644 index 0000000..52f4cb1 --- /dev/null +++ b/internal/catalog/testutil/catalog_spec.go @@ -0,0 +1,52 @@ +// Package testutil provides helpers shared by catalog tests. +package testutil + +import ( + "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/store/model" +) + +// DefaultResourceName is the single-resource name used by most catalog tests. +const DefaultResourceName = "main" + +func ptrAPIFields(fields []v1alpha1.FieldConfiguration) *[]v1alpha1.FieldConfiguration { + return &fields +} + +// CatalogSpec builds a single-resource CatalogItemSpec for API-layer tests. +func CatalogSpec(serviceType string, fields []v1alpha1.FieldConfiguration) v1alpha1.CatalogItemSpec { + return v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{{ + Name: DefaultResourceName, + ServiceType: serviceType, + Fields: ptrAPIFields(fields), + }}, + } +} + +// CatalogSpecVM builds a single-resource VM catalog item spec. +func CatalogSpecVM(fields []v1alpha1.FieldConfiguration) v1alpha1.CatalogItemSpec { + return CatalogSpec("vm", fields) +} + +// CatalogSpecContainer builds a single-resource container catalog item spec. +func CatalogSpecContainer(fields []v1alpha1.FieldConfiguration) v1alpha1.CatalogItemSpec { + return CatalogSpec("container", fields) +} + +// PtrCatalogSpec returns a pointer to CatalogSpec. +func PtrCatalogSpec(serviceType string, fields []v1alpha1.FieldConfiguration) *v1alpha1.CatalogItemSpec { + s := CatalogSpec(serviceType, fields) + return &s +} + +// ModelCatalogSpec builds a single-resource CatalogItemSpec for store-layer tests. +func ModelCatalogSpec(serviceType string, fields []model.FieldConfiguration) model.CatalogItemSpec { + return model.CatalogItemSpec{ + Resources: []model.CatalogResource{{ + Name: DefaultResourceName, + ServiceType: serviceType, + Fields: fields, + }}, + } +} diff --git a/test/subsystem/catalog/catalog_item_instance_test.go b/test/subsystem/catalog/catalog_item_instance_test.go index f14f01a..112b95d 100644 --- a/test/subsystem/catalog/catalog_item_instance_test.go +++ b/test/subsystem/catalog/catalog_item_instance_test.go @@ -1,4 +1,5 @@ //go:build subsystem + package subsystem_test import ( @@ -9,6 +10,7 @@ import ( . "github.com/onsi/gomega" v1alpha1 "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/testutil" "github.com/google/uuid" ) @@ -54,7 +56,7 @@ var _ = Describe("CatalogItemInstance API", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: catalogItemID, UserValues: []v1alpha1.UserValue{ - {Path: "vcpu.count", Value: float64(4)}, + {Resource: testutil.DefaultResourceName, Path: "vcpu.count", Value: float64(4)}, }, }, } @@ -65,9 +67,10 @@ var _ = Describe("CatalogItemInstance API", func() { Expect(resp.JSON201).NotTo(BeNil()) Expect(*resp.JSON201.Uid).To(Equal(instID)) Expect(*resp.JSON201.Path).To(Equal("catalog-item-instances/" + instID)) - Expect(resp.JSON201.ResourceId).NotTo(BeNil()) - Expect(*resp.JSON201.ResourceId).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) - Expect(*resp.JSON201.ResourceId).NotTo(Equal(instID)) + Expect(resp.JSON201.Spec.ResourceIds).NotTo(BeNil()) + Expect(*resp.JSON201.Spec.ResourceIds).To(HaveLen(1)) + Expect((*resp.JSON201.Spec.ResourceIds)[0]).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) + Expect((*resp.JSON201.Spec.ResourceIds)[0]).NotTo(Equal(instID)) verifyPMCreateResourceCalled(1) }) @@ -88,8 +91,9 @@ var _ = Describe("CatalogItemInstance API", func() { Expect(err).NotTo(HaveOccurred()) Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) Expect(*resp.JSON201.Uid).To(Equal(instID)) - Expect(resp.JSON201.ResourceId).NotTo(BeNil()) - Expect(*resp.JSON201.ResourceId).NotTo(Equal(instID)) + Expect(resp.JSON201.Spec.ResourceIds).NotTo(BeNil()) + Expect(*resp.JSON201.Spec.ResourceIds).To(HaveLen(1)) + Expect((*resp.JSON201.Spec.ResourceIds)[0]).NotTo(Equal(instID)) }) It("returns 400 for non-existent catalog_item_id", func() { @@ -119,7 +123,7 @@ var _ = Describe("CatalogItemInstance API", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: catalogItemID, UserValues: []v1alpha1.UserValue{ - {Path: "nonexistent.path", Value: "bad"}, + {Resource: testutil.DefaultResourceName, Path: "nonexistent.path", Value: "bad"}, }, }, } @@ -139,7 +143,7 @@ var _ = Describe("CatalogItemInstance API", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: catalogItemID, UserValues: []v1alpha1.UserValue{ - {Path: "memory.size_gb", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "memory.size_gb", Value: float64(8)}, }, }, } @@ -159,7 +163,7 @@ var _ = Describe("CatalogItemInstance API", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: catalogItemID, UserValues: []v1alpha1.UserValue{ - {Path: "vcpu.count", Value: float64(99)}, // exceeds maximum of 16 + {Resource: testutil.DefaultResourceName, Path: "vcpu.count", Value: float64(99)}, // exceeds maximum of 16 }, }, } @@ -179,8 +183,8 @@ var _ = Describe("CatalogItemInstance API", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "pet-clinic", UserValues: []v1alpha1.UserValue{ - {Path: "database.engine", Value: "postgres"}, - {Path: "database.version", Value: "8.4"}, // 8.4 is only allowed for mysql, not postgres + {Resource: "app", Path: "database.engine", Value: "postgres"}, + {Resource: "app", Path: "database.version", Value: "8.4"}, // 8.4 is only allowed for mysql, not postgres }, }, } @@ -214,8 +218,8 @@ var _ = Describe("CatalogItemInstance API", func() { Expect(resp.JSON200).NotTo(BeNil()) Expect(*resp.JSON200.Uid).To(Equal(instID)) Expect(resp.JSON200.DisplayName).To(Equal("Get Instance")) - Expect(resp.JSON200.ResourceId).NotTo(BeNil()) - Expect(*resp.JSON200.ResourceId).To(Equal(*createResp.JSON201.ResourceId)) + Expect(resp.JSON200.Spec.ResourceIds).NotTo(BeNil()) + Expect(*resp.JSON200.Spec.ResourceIds).To(Equal(*createResp.JSON201.Spec.ResourceIds)) }) It("returns 404 for non-existent instance", func() { @@ -389,7 +393,7 @@ var _ = Describe("CatalogItemInstance API", func() { stubPMRehydrateResource() }) - It("returns 200 with updated resource_id", func() { + It("returns 200 with updated resource_ids", func() { instID := "inst-rehy-" + uuid.NewString()[:8] params := &v1alpha1.CreateCatalogItemInstanceParams{Id: &instID} body := v1alpha1.CatalogItemInstance{ @@ -403,24 +407,25 @@ var _ = Describe("CatalogItemInstance API", func() { createResp, err := apiClient.CreateCatalogItemInstanceWithResponse(context.Background(), params, body) Expect(err).NotTo(HaveOccurred()) Expect(createResp.StatusCode()).To(Equal(http.StatusCreated)) - oldResourceID := *createResp.JSON201.ResourceId + oldResourceIDs := *createResp.JSON201.Spec.ResourceIds resp, err := apiClient.RehydrateCatalogItemInstanceWithResponse(context.Background(), instID) Expect(err).NotTo(HaveOccurred()) Expect(resp.StatusCode()).To(Equal(http.StatusOK)) Expect(resp.JSON200).NotTo(BeNil()) Expect(*resp.JSON200.Uid).To(Equal(instID)) - Expect(resp.JSON200.ResourceId).NotTo(BeNil()) - Expect(*resp.JSON200.ResourceId).NotTo(Equal(oldResourceID)) - Expect(*resp.JSON200.ResourceId).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) + Expect(resp.JSON200.Spec.ResourceIds).NotTo(BeNil()) + Expect(*resp.JSON200.Spec.ResourceIds).To(HaveLen(1)) + Expect(*resp.JSON200.Spec.ResourceIds).NotTo(Equal(oldResourceIDs)) + Expect((*resp.JSON200.Spec.ResourceIds)[0]).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) verifyPMRehydrateResourceCalled(1) - // Verify resource_id is persisted + // Verify resource_ids are persisted getResp, err := apiClient.GetCatalogItemInstanceWithResponse(context.Background(), instID) Expect(err).NotTo(HaveOccurred()) Expect(getResp.StatusCode()).To(Equal(http.StatusOK)) - Expect(*getResp.JSON200.ResourceId).To(Equal(*resp.JSON200.ResourceId)) + Expect(*getResp.JSON200.Spec.ResourceIds).To(Equal(*resp.JSON200.Spec.ResourceIds)) }) It("returns 404 for non-existent instance", func() { @@ -429,7 +434,7 @@ var _ = Describe("CatalogItemInstance API", func() { Expect(resp.StatusCode()).To(Equal(http.StatusNotFound)) }) - It("returns 406 when PM rehydrate returns policy rejected, resource_id unchanged", func() { + It("returns 406 when PM rehydrate returns policy rejected, resource_ids unchanged", func() { instID := "inst-rehy-policy-" + uuid.NewString()[:8] params := &v1alpha1.CreateCatalogItemInstanceParams{Id: &instID} body := v1alpha1.CatalogItemInstance{ @@ -443,7 +448,7 @@ var _ = Describe("CatalogItemInstance API", func() { createResp, err := apiClient.CreateCatalogItemInstanceWithResponse(context.Background(), params, body) Expect(err).NotTo(HaveOccurred()) Expect(createResp.StatusCode()).To(Equal(http.StatusCreated)) - oldResourceID := *createResp.JSON201.ResourceId + oldResourceIDs := *createResp.JSON201.Spec.ResourceIds // Reset WireMock and stub rehydrate as policy rejected resetWireMock() @@ -455,14 +460,14 @@ var _ = Describe("CatalogItemInstance API", func() { Expect(err).NotTo(HaveOccurred()) Expect(resp.StatusCode()).To(Equal(http.StatusNotAcceptable)) - // Verify resource_id is unchanged + // Verify resource_ids are unchanged getResp, err := apiClient.GetCatalogItemInstanceWithResponse(context.Background(), instID) Expect(err).NotTo(HaveOccurred()) Expect(getResp.StatusCode()).To(Equal(http.StatusOK)) - Expect(*getResp.JSON200.ResourceId).To(Equal(oldResourceID)) + Expect(*getResp.JSON200.Spec.ResourceIds).To(Equal(oldResourceIDs)) }) - It("returns 422 when PM rehydrate returns provider error, resource_id unchanged", func() { + It("returns 422 when PM rehydrate returns provider error, resource_ids unchanged", func() { instID := "inst-rehy-provider-" + uuid.NewString()[:8] params := &v1alpha1.CreateCatalogItemInstanceParams{Id: &instID} body := v1alpha1.CatalogItemInstance{ @@ -476,7 +481,7 @@ var _ = Describe("CatalogItemInstance API", func() { createResp, err := apiClient.CreateCatalogItemInstanceWithResponse(context.Background(), params, body) Expect(err).NotTo(HaveOccurred()) Expect(createResp.StatusCode()).To(Equal(http.StatusCreated)) - oldResourceID := *createResp.JSON201.ResourceId + oldResourceIDs := *createResp.JSON201.Spec.ResourceIds // Reset WireMock and stub rehydrate as provider error resetWireMock() @@ -488,14 +493,14 @@ var _ = Describe("CatalogItemInstance API", func() { Expect(err).NotTo(HaveOccurred()) Expect(resp.StatusCode()).To(Equal(http.StatusUnprocessableEntity)) - // Verify resource_id is unchanged + // Verify resource_ids are unchanged getResp, err := apiClient.GetCatalogItemInstanceWithResponse(context.Background(), instID) Expect(err).NotTo(HaveOccurred()) Expect(getResp.StatusCode()).To(Equal(http.StatusOK)) - Expect(*getResp.JSON200.ResourceId).To(Equal(oldResourceID)) + Expect(*getResp.JSON200.Spec.ResourceIds).To(Equal(oldResourceIDs)) }) - It("returns 500 when PM rehydrate fails, resource_id unchanged", func() { + It("returns 500 when PM rehydrate fails, resource_ids unchanged", func() { instID := "inst-rehy-fail-" + uuid.NewString()[:8] params := &v1alpha1.CreateCatalogItemInstanceParams{Id: &instID} body := v1alpha1.CatalogItemInstance{ @@ -509,7 +514,7 @@ var _ = Describe("CatalogItemInstance API", func() { createResp, err := apiClient.CreateCatalogItemInstanceWithResponse(context.Background(), params, body) Expect(err).NotTo(HaveOccurred()) Expect(createResp.StatusCode()).To(Equal(http.StatusCreated)) - oldResourceID := *createResp.JSON201.ResourceId + oldResourceIDs := *createResp.JSON201.Spec.ResourceIds // Reset WireMock and stub rehydrate as failure resetWireMock() @@ -521,11 +526,11 @@ var _ = Describe("CatalogItemInstance API", func() { Expect(err).NotTo(HaveOccurred()) Expect(resp.StatusCode()).To(Equal(http.StatusInternalServerError)) - // Verify resource_id is unchanged + // Verify resource_ids are unchanged getResp, err := apiClient.GetCatalogItemInstanceWithResponse(context.Background(), instID) Expect(err).NotTo(HaveOccurred()) Expect(getResp.StatusCode()).To(Equal(http.StatusOK)) - Expect(*getResp.JSON200.ResourceId).To(Equal(oldResourceID)) + Expect(*getResp.JSON200.Spec.ResourceIds).To(Equal(oldResourceIDs)) }) }) diff --git a/test/subsystem/catalog/catalog_item_test.go b/test/subsystem/catalog/catalog_item_test.go index 691293c..191028e 100644 --- a/test/subsystem/catalog/catalog_item_test.go +++ b/test/subsystem/catalog/catalog_item_test.go @@ -1,4 +1,5 @@ //go:build subsystem + package subsystem_test import ( @@ -9,6 +10,7 @@ import ( . "github.com/onsi/gomega" v1alpha1 "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/testutil" "github.com/google/uuid" ) @@ -26,10 +28,8 @@ var _ = Describe("CatalogItem API", func() { Expect(*item.Uid).To(Equal(id)) Expect(item.Path).NotTo(BeNil()) Expect(*item.Path).To(Equal("catalog-items/" + id)) - Expect(item.DisplayName).NotTo(BeNil()) Expect(*item.DisplayName).To(Equal("Test Item")) - Expect(item.Spec).NotTo(BeNil()) - Expect(*item.Spec.ServiceType).To(Equal("vm")) + Expect(item.Spec.Resources[0].ServiceType).To(Equal("vm")) }) It("uses user-specified ID when provided", func() { @@ -45,14 +45,10 @@ var _ = Describe("CatalogItem API", func() { createTestCatalogItem(id, "First", "vm", nil) params := &v1alpha1.CreateCatalogItemParams{Id: &id} - fields := []v1alpha1.FieldConfiguration{defaultField()} body := v1alpha1.CatalogItem{ ApiVersion: stringPtr("v1alpha1"), DisplayName: stringPtr("Second"), - Spec: &v1alpha1.CatalogItemSpec{ - ServiceType: stringPtr("vm"), - Fields: &fields, - }, + Spec: testutil.PtrCatalogSpec("vm", []v1alpha1.FieldConfiguration{defaultField()}), } resp, err := apiClient.CreateCatalogItemWithResponse(context.Background(), params, body) Expect(err).NotTo(HaveOccurred()) @@ -63,14 +59,10 @@ var _ = Describe("CatalogItem API", func() { It("returns 400 for non-existent service type", func() { id := "ci-badst-" + uuid.NewString()[:8] params := &v1alpha1.CreateCatalogItemParams{Id: &id} - fields := []v1alpha1.FieldConfiguration{defaultField()} body := v1alpha1.CatalogItem{ ApiVersion: stringPtr("v1alpha1"), DisplayName: stringPtr("Bad ST"), - Spec: &v1alpha1.CatalogItemSpec{ - ServiceType: stringPtr("nonexistent-service-type"), - Fields: &fields, - }, + Spec: testutil.PtrCatalogSpec("nonexistent-service-type", nil), } resp, err := apiClient.CreateCatalogItemWithResponse(context.Background(), params, body) Expect(err).NotTo(HaveOccurred()) @@ -129,7 +121,7 @@ var _ = Describe("CatalogItem API", func() { Expect(resp.JSON200).NotTo(BeNil()) for _, item := range resp.JSON200.Results { - Expect(*item.Spec.ServiceType).To(Equal("database")) + Expect(item.Spec.Resources[0].ServiceType).To(Equal("database")) } uids := make([]string, len(resp.JSON200.Results)) for i, item := range resp.JSON200.Results { @@ -144,8 +136,9 @@ var _ = Describe("CatalogItem API", func() { id := "ci-update-" + uuid.NewString()[:8] createTestCatalogItem(id, "Original Name", "vm", nil) + displayName := "Updated Name" updateBody := v1alpha1.CatalogItem{ - DisplayName: stringPtr("Updated Name"), + DisplayName: &displayName, } resp, err := apiClient.UpdateCatalogItemWithApplicationMergePatchPlusJSONBodyWithResponse( context.Background(), id, updateBody, @@ -162,8 +155,9 @@ var _ = Describe("CatalogItem API", func() { }) It("returns 404 for non-existent item", func() { + displayName := "Updated Name" updateBody := v1alpha1.CatalogItem{ - DisplayName: stringPtr("Updated Name"), + DisplayName: &displayName, } resp, err := apiClient.UpdateCatalogItemWithApplicationMergePatchPlusJSONBodyWithResponse( context.Background(), "does-not-exist", updateBody, @@ -176,10 +170,9 @@ var _ = Describe("CatalogItem API", func() { id := "ci-immutable-" + uuid.NewString()[:8] createTestCatalogItem(id, "Immutable ST", "vm", nil) + spec := testutil.CatalogSpec("database", nil) updateBody := v1alpha1.CatalogItem{ - Spec: &v1alpha1.CatalogItemSpec{ - ServiceType: stringPtr("database"), - }, + Spec: &spec, } resp, err := apiClient.UpdateCatalogItemWithApplicationMergePatchPlusJSONBodyWithResponse( context.Background(), id, updateBody, diff --git a/test/subsystem/catalog/setup_test.go b/test/subsystem/catalog/setup_test.go index 9bc48d1..2a1b0ca 100644 --- a/test/subsystem/catalog/setup_test.go +++ b/test/subsystem/catalog/setup_test.go @@ -1,4 +1,5 @@ //go:build subsystem + package subsystem_test import ( @@ -12,6 +13,7 @@ import ( . "github.com/onsi/gomega" v1alpha1 "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) // --- WireMock helpers --- @@ -28,7 +30,7 @@ func resetWireMock() { func stubPMCreateResource() { stub := map[string]any{ "request": map[string]any{ - "method": "POST", + "method": "POST", "urlPattern": "/api/v1alpha1/resources.*", }, "response": map[string]any{ @@ -91,7 +93,7 @@ func stubPMRehydrateResourceFailure() { func stubPMDeleteResource() { stub := map[string]any{ "request": map[string]any{ - "method": "DELETE", + "method": "DELETE", "urlPathPattern": "/api/v1alpha1/resources/.*", }, "response": map[string]any{ @@ -192,7 +194,7 @@ func stubPMRehydrateResourceProviderError() { func stubPMCreateResourceFailure() { stub := map[string]any{ "request": map[string]any{ - "method": "POST", + "method": "POST", "urlPattern": "/api/v1alpha1/resources.*", }, "response": map[string]any{ @@ -213,7 +215,7 @@ func stubPMCreateResourceFailure() { func stubPMDeleteResourceFailure() { stub := map[string]any{ "request": map[string]any{ - "method": "DELETE", + "method": "DELETE", "urlPathPattern": "/api/v1alpha1/resources/.*", }, "response": map[string]any{ @@ -312,10 +314,7 @@ func createTestCatalogItem(id, displayName, serviceType string, fields []v1alpha body := v1alpha1.CatalogItem{ ApiVersion: stringPtr("v1alpha1"), DisplayName: &displayName, - Spec: &v1alpha1.CatalogItemSpec{ - ServiceType: &serviceType, - Fields: &fields, - }, + Spec: testutil.PtrCatalogSpec(serviceType, fields), } resp, err := apiClient.CreateCatalogItemWithResponse(context.Background(), params, body) ExpectWithOffset(1, err).NotTo(HaveOccurred()) diff --git a/test/subsystem/catalog/suite_test.go b/test/subsystem/catalog/suite_test.go index c9b3639..773a348 100644 --- a/test/subsystem/catalog/suite_test.go +++ b/test/subsystem/catalog/suite_test.go @@ -1,4 +1,5 @@ //go:build subsystem + package subsystem_test import ( @@ -15,9 +16,9 @@ import ( ) var ( - apiClient *client.ClientWithResponses - wireMockURL string - httpClient = &http.Client{Timeout: 10 * time.Second} + apiClient *client.ClientWithResponses + wireMockURL string + httpClient = &http.Client{Timeout: 10 * time.Second} ) func TestSubsystem(t *testing.T) {