diff --git a/plugins/nemo-deployments/README.md b/plugins/nemo-deployments/README.md new file mode 100644 index 0000000000..00b3437173 --- /dev/null +++ b/plugins/nemo-deployments/README.md @@ -0,0 +1,11 @@ +# NeMo Deployments Plugin + +Substrate-agnostic deployment lifecycle for the NeMo Platform: entity schemas, +CRUD APIs, a `DeploymentBackend` ABC, and an executor registry. + +## Tests + +```bash +uv sync +uv run pytest plugins/nemo-deployments/tests/unit -v +``` diff --git a/plugins/nemo-deployments/openapi/openapi.yaml b/plugins/nemo-deployments/openapi/openapi.yaml new file mode 100644 index 0000000000..baff6bebcc --- /dev/null +++ b/plugins/nemo-deployments/openapi/openapi.yaml @@ -0,0 +1,1560 @@ +openapi: 3.1.0 +info: + title: deployments (plugin) + version: 0.1.3 +paths: + /apis/deployments/v2/workspaces/{workspace}/deployment-configs: + post: + tags: + - Deployment Configs + summary: Create Deployment Config + operationId: create_deployment_config_apis_deployments_v2_workspaces__workspace__deployment_configs_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDeploymentConfigRequest' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentConfig' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Deployment Configs + summary: List Deployment Configs + operationId: list_deployment_configs_apis_deployments_v2_workspaces__workspace__deployment_configs_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + default: 20 + title: Page Size + - name: sort + in: query + required: false + schema: + type: string + default: -created_at + title: Sort + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/NemoListResponse_DeploymentConfig_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/deployments/v2/workspaces/{workspace}/deployment-configs/{name}: + get: + tags: + - Deployment Configs + summary: Get Deployment Config + operationId: get_deployment_config_apis_deployments_v2_workspaces__workspace__deployment_configs__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentConfig' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Deployment Configs + summary: Delete Deployment Config + operationId: delete_deployment_config_apis_deployments_v2_workspaces__workspace__deployment_configs__name__delete + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/deployments/v2/workspaces/{workspace}/deployments: + post: + tags: + - Deployments + summary: Create Deployment + operationId: create_deployment_apis_deployments_v2_workspaces__workspace__deployments_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDeploymentRequest' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Deployment' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Deployments + summary: List Deployments + operationId: list_deployments_apis_deployments_v2_workspaces__workspace__deployments_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + default: 20 + title: Page Size + - name: sort + in: query + required: false + schema: + type: string + default: -created_at + title: Sort + - name: status_in + in: query + required: false + schema: + description: Comma-separated deployment statuses for bulk reconciler queries. + title: Status In + type: string + description: Comma-separated deployment statuses for bulk reconciler queries. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/NemoListResponse_Deployment_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/deployments/v2/workspaces/{workspace}/deployments/{name}: + get: + tags: + - Deployments + summary: Get Deployment + operationId: get_deployment_apis_deployments_v2_workspaces__workspace__deployments__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Deployment' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Deployments + summary: Delete Deployment + operationId: delete_deployment_apis_deployments_v2_workspaces__workspace__deployments__name__delete + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/deployments/v2/workspaces/{workspace}/deployments/{name}/status: + put: + tags: + - Deployment Status + summary: Update Deployment Status + operationId: update_deployment_status_apis_deployments_v2_workspaces__workspace__deployments__name__status_put + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateDeploymentStatusRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Deployment' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/deployments/v2/workspaces/{workspace}/volumes: + post: + tags: + - Volumes + summary: Create Volume + operationId: create_volume_apis_deployments_v2_workspaces__workspace__volumes_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateVolumeRequest' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Volume' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Volumes + summary: List Volumes + operationId: list_volumes_apis_deployments_v2_workspaces__workspace__volumes_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + default: 20 + title: Page Size + - name: sort + in: query + required: false + schema: + type: string + default: -created_at + title: Sort + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/NemoListResponse_Volume_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/deployments/v2/workspaces/{workspace}/volumes/{name}: + get: + tags: + - Volumes + summary: Get Volume + operationId: get_volume_apis_deployments_v2_workspaces__workspace__volumes__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Volume' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Volumes + summary: Delete Volume + operationId: delete_volume_apis_deployments_v2_workspaces__workspace__volumes__name__delete + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/deployments/v2/workspaces/{workspace}/volumes/{name}/status: + put: + tags: + - Volume Status + summary: Update Volume Status + operationId: update_volume_status_apis_deployments_v2_workspaces__workspace__volumes__name__status_put + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateVolumeStatusRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Volume' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + Affinity: + properties: + nodeAffinity: + title: Nodeaffinity + additionalProperties: true + type: object + podAffinity: + title: Podaffinity + additionalProperties: true + type: object + podAntiAffinity: + title: Podantiaffinity + additionalProperties: true + type: object + type: object + title: Affinity + ConfigFile: + properties: + path: + type: string + title: Path + content: + type: string + title: Content + mode: + type: integer + title: Mode + default: 420 + type: object + required: + - path + - content + title: ConfigFile + ContainerInput: + properties: + name: + type: string + title: Name + image: + type: string + title: Image + command: + items: + type: string + type: array + title: Command + args: + items: + type: string + type: array + title: Args + env: + items: + $ref: '#/components/schemas/EnvVar' + type: array + title: Env + ports: + items: + $ref: '#/components/schemas/ContainerPort' + type: array + title: Ports + resources: + $ref: '#/components/schemas/ResourceRequirements' + volumeMounts: + items: + $ref: '#/components/schemas/VolumeMount' + type: array + title: Volumemounts + livenessProbe: + $ref: '#/components/schemas/Probe' + readinessProbe: + $ref: '#/components/schemas/Probe' + type: object + required: + - name + - image + title: ContainerInput + ContainerOutput: + properties: + name: + type: string + title: Name + image: + type: string + title: Image + command: + items: + type: string + type: array + title: Command + args: + items: + type: string + type: array + title: Args + env: + items: + $ref: '#/components/schemas/EnvVar' + type: array + title: Env + ports: + items: + $ref: '#/components/schemas/ContainerPort' + type: array + title: Ports + resources: + $ref: '#/components/schemas/ResourceRequirements' + volumeMounts: + items: + $ref: '#/components/schemas/VolumeMount' + type: array + title: Volumemounts + livenessProbe: + $ref: '#/components/schemas/Probe' + readinessProbe: + $ref: '#/components/schemas/Probe' + type: object + required: + - name + - image + title: ContainerOutput + ContainerPort: + properties: + name: + title: Name + type: string + containerPort: + type: integer + title: Containerport + protocol: + type: string + enum: + - TCP + - UDP + title: Protocol + default: TCP + type: object + required: + - containerPort + title: ContainerPort + CreateDeploymentConfigRequest: + properties: + name: + type: string + title: Name + containers: + items: + $ref: '#/components/schemas/ContainerInput' + type: array + title: Containers + init_containers: + items: + $ref: '#/components/schemas/ContainerInput' + type: array + title: Init Containers + volume_mounts: + items: + $ref: '#/components/schemas/VolumeMount' + type: array + title: Volume Mounts + config_files: + items: + $ref: '#/components/schemas/ConfigFile' + type: array + title: Config Files + restart_policy: + type: string + enum: + - Always + - OnFailure + - Never + title: Restart Policy + default: Always + backoff_limit: + type: integer + title: Backoff Limit + default: 6 + prerequisites: + items: + $ref: '#/components/schemas/Prerequisite' + type: array + title: Prerequisites + drift_recovery: + $ref: '#/components/schemas/DriftRecoveryPolicy' + labels: + additionalProperties: + type: string + type: object + title: Labels + backend_config: + $ref: '#/components/schemas/DeploymentBackendConfigInput' + type: object + required: + - name + title: CreateDeploymentConfigRequest + CreateDeploymentRequest: + properties: + name: + type: string + title: Name + deployment_config: + type: string + title: Deployment Config + description: DeploymentConfig name in this workspace, or workspace/name + for cross-workspace refs. + desired_state: + type: string + enum: + - READY + - STOPPED + title: Desired State + default: READY + executor: + title: Executor + type: string + type: object + required: + - name + - deployment_config + title: CreateDeploymentRequest + CreateVolumeRequest: + properties: + name: + type: string + title: Name + size: + type: string + title: Size + default: 1Gi + access_modes: + items: + type: string + enum: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + type: array + title: Access Modes + backend_config: + $ref: '#/components/schemas/VolumeBackendConfig' + type: object + required: + - name + title: CreateVolumeRequest + Deployment: + properties: + name: + type: string + title: Name + description: Entity name within the workspace + default: '' + workspace: + type: string + pattern: ^[\w\-\+.@:]+$ + title: Workspace + description: Workspace identifier + project: + title: Project + description: The name of the project associated with this entity. + type: string + deployment_config: + type: string + title: Deployment Config + description: Name of the DeploymentConfig entity. + desired_state: + type: string + enum: + - READY + - STOPPED + title: Desired State + default: READY + executor: + title: Executor + description: Named executor registry entry; falls back to plugin default_executor. + type: string + status: + type: string + enum: + - PENDING + - STARTING + - READY + - SUCCEEDED + - FAILED + - LOST + - DELETING + title: Status + default: PENDING + status_message: + type: string + title: Status Message + default: '' + endpoints: + items: + $ref: '#/components/schemas/Endpoint' + type: array + title: Endpoints + exit_code: + title: Exit Code + type: integer + error_details: + title: Error Details + additionalProperties: true + type: object + status_history: + items: + $ref: '#/components/schemas/StatusEvent' + type: array + title: Status History + id: + type: string + title: Id + readOnly: true + created_at: + title: Created At + readOnly: true + type: string + format: date-time + created_by: + title: Created By + readOnly: true + nullable: true + type: string + updated_at: + title: Updated At + readOnly: true + type: string + format: date-time + updated_by: + title: Updated By + readOnly: true + nullable: true + type: string + entity_id: + type: string + title: Entity Id + description: Alias for id for backwards compatibility. + readOnly: true + parent: + title: Parent + description: Parent entity ID for nested entities. + readOnly: true + type: string + type: object + required: + - workspace + - deployment_config + - id + - created_at + - created_by + - updated_at + - updated_by + - entity_id + - parent + title: Deployment + description: Desired and observed deployment state. + DeploymentBackendConfigInput: + properties: + docker: + $ref: '#/components/schemas/DockerDeploymentConfig' + k8s: + $ref: '#/components/schemas/K8sDeploymentConfig' + type: object + title: DeploymentBackendConfigInput + DeploymentBackendConfigOutput: + properties: + docker: + $ref: '#/components/schemas/DockerDeploymentConfig' + k8s: + $ref: '#/components/schemas/K8sDeploymentConfig' + type: object + title: DeploymentBackendConfigOutput + DeploymentConfig: + properties: + name: + type: string + title: Name + description: Entity name within the workspace + default: '' + workspace: + type: string + pattern: ^[\w\-\+.@:]+$ + title: Workspace + description: Workspace identifier + project: + title: Project + description: The name of the project associated with this entity. + type: string + containers: + items: + $ref: '#/components/schemas/ContainerOutput' + type: array + title: Containers + initContainers: + items: + $ref: '#/components/schemas/ContainerOutput' + type: array + title: Initcontainers + volumeMounts: + items: + $ref: '#/components/schemas/VolumeMount' + type: array + title: Volumemounts + configFiles: + items: + $ref: '#/components/schemas/ConfigFile' + type: array + title: Configfiles + restartPolicy: + type: string + enum: + - Always + - OnFailure + - Never + title: Restartpolicy + default: Always + backoffLimit: + type: integer + title: Backofflimit + default: 6 + prerequisites: + items: + $ref: '#/components/schemas/Prerequisite' + type: array + title: Prerequisites + driftRecovery: + $ref: '#/components/schemas/DriftRecoveryPolicy' + labels: + additionalProperties: + type: string + type: object + title: Labels + backendConfig: + $ref: '#/components/schemas/DeploymentBackendConfigOutput' + id: + type: string + title: Id + readOnly: true + created_at: + title: Created At + readOnly: true + type: string + format: date-time + created_by: + title: Created By + readOnly: true + nullable: true + type: string + updated_at: + title: Updated At + readOnly: true + type: string + format: date-time + updated_by: + title: Updated By + readOnly: true + nullable: true + type: string + entity_id: + type: string + title: Entity Id + description: Alias for id for backwards compatibility. + readOnly: true + parent: + title: Parent + description: Parent entity ID for nested entities. + readOnly: true + type: string + type: object + required: + - workspace + - id + - created_at + - created_by + - updated_at + - updated_by + - entity_id + - parent + title: DeploymentConfig + description: Immutable PodSpec-shaped deployment template. + DockerDeploymentConfig: + properties: + port_range_start: + type: integer + title: Port Range Start + default: 9000 + port_range_end: + type: integer + title: Port Range End + default: 9100 + network: + title: Network + type: string + type: object + title: DockerDeploymentConfig + DockerVolumeConfig: + properties: + driver: + type: string + title: Driver + default: local + mount_point: + title: Mount Point + type: string + type: object + title: DockerVolumeConfig + DriftRecoveryPolicy: + properties: + action: + type: string + enum: + - recreate + - ignore + title: Action + default: recreate + type: object + title: DriftRecoveryPolicy + Endpoint: + properties: + name: + type: string + title: Name + url: + type: string + title: Url + protocol: + type: string + enum: + - http + - https + - grpc + - tcp + title: Protocol + default: http + type: object + required: + - name + - url + title: Endpoint + EnvVar: + properties: + name: + type: string + title: Name + value: + title: Value + type: string + valueFrom: + title: Valuefrom + additionalProperties: true + type: object + type: object + required: + - name + title: EnvVar + ExecAction: + properties: + command: + items: + type: string + type: array + title: Command + type: object + title: ExecAction + HTTPGetAction: + properties: + path: + type: string + title: Path + default: / + port: + anyOf: + - type: integer + - type: string + title: Port + default: 8080 + scheme: + type: string + enum: + - HTTP + - HTTPS + title: Scheme + default: HTTP + type: object + title: HTTPGetAction + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + K8sDeploymentConfig: + properties: + namespace: + title: Namespace + type: string + serviceAccount: + title: Serviceaccount + type: string + tolerations: + items: + $ref: '#/components/schemas/Toleration' + type: array + title: Tolerations + affinity: + $ref: '#/components/schemas/Affinity' + securityContext: + $ref: '#/components/schemas/PodSecurityContext' + type: object + title: K8sDeploymentConfig + K8sVolumeConfig: + properties: + storageClass: + title: Storageclass + type: string + namespace: + title: Namespace + type: string + type: object + title: K8sVolumeConfig + NemoListResponse_DeploymentConfig_: + properties: + data: + items: + $ref: '#/components/schemas/DeploymentConfig' + type: array + title: Data + pagination: + allOf: + - $ref: '#/components/schemas/PaginationData' + description: "Pagination metadata \u2014 page, page_size, total_results,\ + \ etc." + sort: + title: Sort + description: Sort field applied to this result set (e.g. '-created_at'). + type: string + filter: + title: Filter + description: Filter criteria echoed back from the request. + type: object + required: + - data + title: NemoListResponse_DeploymentConfig_ + NemoListResponse_Deployment_: + properties: + data: + items: + $ref: '#/components/schemas/Deployment' + type: array + title: Data + pagination: + allOf: + - $ref: '#/components/schemas/PaginationData' + description: "Pagination metadata \u2014 page, page_size, total_results,\ + \ etc." + sort: + title: Sort + description: Sort field applied to this result set (e.g. '-created_at'). + type: string + filter: + title: Filter + description: Filter criteria echoed back from the request. + type: object + required: + - data + title: NemoListResponse_Deployment_ + NemoListResponse_Volume_: + properties: + data: + items: + $ref: '#/components/schemas/Volume' + type: array + title: Data + pagination: + allOf: + - $ref: '#/components/schemas/PaginationData' + description: "Pagination metadata \u2014 page, page_size, total_results,\ + \ etc." + sort: + title: Sort + description: Sort field applied to this result set (e.g. '-created_at'). + type: string + filter: + title: Filter + description: Filter criteria echoed back from the request. + type: object + required: + - data + title: NemoListResponse_Volume_ + PaginationData: + properties: + page: + type: integer + title: Page + description: The current page number. + page_size: + type: integer + title: Page Size + description: The page size used for the query. + current_page_size: + type: integer + title: Current Page Size + description: The size for the current page. + total_pages: + type: integer + title: Total Pages + description: The total number of pages. + total_results: + type: integer + title: Total Results + description: The total number of results. + type: object + required: + - page + - page_size + - current_page_size + - total_pages + - total_results + title: PaginationData + PodSecurityContext: + properties: + runAsUser: + title: Runasuser + type: integer + runAsGroup: + title: Runasgroup + type: integer + fsGroup: + title: Fsgroup + type: integer + type: object + title: PodSecurityContext + Prerequisite: + properties: + deployment_name: + type: string + title: Deployment Name + description: Name of another DeploymentConfig in the same workspace that + must reach a ready terminal state before this config's deployment may + start. + type: object + required: + - deployment_name + title: Prerequisite + Probe: + properties: + exec: + $ref: '#/components/schemas/ExecAction' + httpGet: + $ref: '#/components/schemas/HTTPGetAction' + tcpSocket: + $ref: '#/components/schemas/TCPSocketAction' + initialDelaySeconds: + type: integer + title: Initialdelayseconds + default: 0 + periodSeconds: + type: integer + title: Periodseconds + default: 10 + timeoutSeconds: + type: integer + title: Timeoutseconds + default: 1 + failureThreshold: + type: integer + title: Failurethreshold + default: 3 + type: object + title: Probe + ResourceRequirements: + properties: + limits: + additionalProperties: + type: string + type: object + title: Limits + requests: + additionalProperties: + type: string + type: object + title: Requests + type: object + title: ResourceRequirements + StatusEvent: + properties: + status: + type: string + enum: + - PENDING + - STARTING + - READY + - SUCCEEDED + - FAILED + - LOST + - DELETING + title: Status + message: + type: string + title: Message + default: '' + timestamp: + type: string + title: Timestamp + default: '' + type: object + required: + - status + title: StatusEvent + TCPSocketAction: + properties: + port: + anyOf: + - type: integer + - type: string + title: Port + type: object + required: + - port + title: TCPSocketAction + Toleration: + properties: + key: + title: Key + type: string + operator: + type: string + enum: + - Equal + - Exists + title: Operator + default: Equal + value: + title: Value + type: string + effect: + title: Effect + type: string + enum: + - NoSchedule + - PreferNoSchedule + - NoExecute + tolerationSeconds: + title: Tolerationseconds + type: integer + type: object + title: Toleration + UpdateDeploymentStatusRequest: + properties: + status: + type: string + enum: + - PENDING + - STARTING + - READY + - SUCCEEDED + - FAILED + - LOST + - DELETING + title: Status + status_message: + type: string + title: Status Message + default: '' + exit_code: + title: Exit Code + type: integer + error_details: + title: Error Details + additionalProperties: true + type: object + type: object + required: + - status + title: UpdateDeploymentStatusRequest + UpdateVolumeStatusRequest: + properties: + status: + type: string + enum: + - PENDING + - BOUND + - RELEASED + - FAILED + title: Status + status_message: + type: string + title: Status Message + default: '' + error_details: + title: Error Details + additionalProperties: true + type: object + type: object + required: + - status + title: UpdateVolumeStatusRequest + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + input: + title: Input + ctx: + type: object + title: Context + additionalProperties: true + type: object + required: + - loc + - msg + - type + title: ValidationError + Volume: + properties: + name: + type: string + title: Name + description: Entity name within the workspace + default: '' + workspace: + type: string + pattern: ^[\w\-\+.@:]+$ + title: Workspace + description: Workspace identifier + project: + title: Project + description: The name of the project associated with this entity. + type: string + size: + type: string + title: Size + description: Requested storage size (Kubernetes quantity). + default: 1Gi + access_modes: + items: + type: string + enum: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + type: array + title: Access Modes + backendConfig: + $ref: '#/components/schemas/VolumeBackendConfig' + status: + type: string + enum: + - PENDING + - BOUND + - RELEASED + - FAILED + title: Status + default: PENDING + status_message: + type: string + title: Status Message + default: '' + error_details: + title: Error Details + additionalProperties: true + type: object + id: + type: string + title: Id + readOnly: true + created_at: + title: Created At + readOnly: true + type: string + format: date-time + created_by: + title: Created By + readOnly: true + nullable: true + type: string + updated_at: + title: Updated At + readOnly: true + type: string + format: date-time + updated_by: + title: Updated By + readOnly: true + nullable: true + type: string + entity_id: + type: string + title: Entity Id + description: Alias for id for backwards compatibility. + readOnly: true + parent: + title: Parent + description: Parent entity ID for nested entities. + readOnly: true + type: string + type: object + required: + - workspace + - id + - created_at + - created_by + - updated_at + - updated_by + - entity_id + - parent + title: Volume + description: Persistent volume request and observed state. + VolumeBackendConfig: + properties: + docker: + $ref: '#/components/schemas/DockerVolumeConfig' + k8s: + $ref: '#/components/schemas/K8sVolumeConfig' + type: object + title: VolumeBackendConfig + VolumeMount: + properties: + name: + type: string + title: Name + mountPath: + type: string + title: Mountpath + readOnly: + type: boolean + title: Readonly + default: false + subPath: + title: Subpath + type: string + type: object + required: + - name + - mountPath + title: VolumeMount diff --git a/plugins/nemo-deployments/pyproject.toml b/plugins/nemo-deployments/pyproject.toml new file mode 100644 index 0000000000..c4f9f8482e --- /dev/null +++ b/plugins/nemo-deployments/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "nemo-deployments-plugin" +version = "0.1.0" +description = "NeMo Deployments Plugin — substrate-agnostic deployment lifecycle on the NeMo Platform." +readme = "README.md" +requires-python = ">=3.11,<3.14" +dependencies = [ + "fastapi>=0.115", + "nemo-platform", + "nemo-platform-plugin", + "pydantic>=2.10.6", +] + +[project.entry-points."nemo.services"] +deployments = "nemo_deployments_plugin.service:DeploymentsService" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/nemo_deployments_plugin"] + +[tool.uv.sources] +nemo-platform = { workspace = true } +nemo-platform-plugin = { workspace = true } + +[dependency-groups] +dev = ["pytest>=8.3.4", "pytest-asyncio>=0.25.3", "httpx>=0.27", "fastapi>=0.115"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +pythonpath = ["src", "tests/unit"] + +[tool.nemo.openapi] diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/dependencies.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/dependencies.py new file mode 100644 index 0000000000..9471f96780 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/dependencies.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""FastAPI dependencies for the deployments plugin API.""" + +from __future__ import annotations + +from fastapi import HTTPException, Request +from nemo_platform_plugin.entity_client import get_entity_client + +__all__ = ["get_entity_client", "require_service_principal"] + +_PRINCIPAL_ID_HEADER = "X-NMP-Principal-Id" + + +def require_service_principal(request: Request) -> None: + """Restrict controller-only status writes to service principals.""" + principal_id = request.headers.get(_PRINCIPAL_ID_HEADER, "") + if not principal_id.startswith("service:"): + raise HTTPException( + status_code=403, + detail="Status updates require a service principal.", + ) diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/deployment_configs.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/deployment_configs.py new file mode 100644 index 0000000000..7cd5f8300e --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/deployment_configs.py @@ -0,0 +1,154 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""DeploymentConfig CRUD routes.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from nemo_deployments_plugin.api.v2.dependencies import get_entity_client +from nemo_deployments_plugin.entities import DeploymentConfig +from nemo_deployments_plugin.references import deployment_names_using_config +from nemo_deployments_plugin.schema import ( + CreateDeploymentConfigRequest, + DeploymentConfigFilter, + DeploymentConfigPage, +) +from nemo_deployments_plugin.validation import ( + PrerequisiteCycleError, + build_existing_prerequisite_map, + detect_prerequisite_cycle, + prerequisite_names, +) +from nemo_platform_plugin.api.filters import make_filter_obj_dep +from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError +from nemo_platform_plugin.schema import PaginationData + +logger = logging.getLogger(__name__) + +router = APIRouter() + +_config_filter_dep = make_filter_obj_dep(DeploymentConfigFilter) + + +async def _list_all_deployment_configs( + entity_client: NemoEntitiesClient, + workspace: str, +) -> list[DeploymentConfig]: + """Page through all deployment configs for prerequisite graph validation.""" + page = 1 + configs: list[DeploymentConfig] = [] + while True: + result = await entity_client.list( + DeploymentConfig, + workspace=workspace, + page=page, + page_size=100, + ) + configs.extend(result.data) + if result.pagination is None or page >= result.pagination.total_pages: + break + page += 1 + return configs + + +@router.post("/deployment-configs", response_model=DeploymentConfig, status_code=201, tags=["Deployment Configs"]) +async def create_deployment_config( + workspace: str, + body: CreateDeploymentConfigRequest, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> DeploymentConfig: + prereq_names = prerequisite_names(body.prerequisites) + try: + existing_configs = await _list_all_deployment_configs(entity_client, workspace) + existing_map = build_existing_prerequisite_map(existing_configs) + detect_prerequisite_cycle( + config_name=body.name, + prerequisites=prereq_names, + existing=existing_map, + ) + except PrerequisiteCycleError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + config = DeploymentConfig( + name=body.name, + workspace=workspace, + **body.model_dump(exclude={"name"}, exclude_none=True), + ) + try: + return await entity_client.create(config) + except NemoEntityConflictError as exc: + raise HTTPException( + status_code=409, + detail=f"DeploymentConfig '{body.name}' already exists in workspace '{workspace}'.", + ) from exc + + +@router.get("/deployment-configs", response_model=DeploymentConfigPage, tags=["Deployment Configs"]) +async def list_deployment_configs( + workspace: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + sort: str = Query(default="-created_at"), + filter: DeploymentConfigFilter = Depends(_config_filter_dep), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> DeploymentConfigPage: + filter_dict = filter if isinstance(filter, dict) else filter.model_dump(exclude_none=True) + result = await entity_client.list( + DeploymentConfig, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter_obj=filter_dict or None, + ) + pagination = PaginationData.model_validate(result.pagination.model_dump()) if result.pagination else None + return DeploymentConfigPage(data=result.data, pagination=pagination, sort=sort, filter=filter) + + +@router.get("/deployment-configs/{name}", response_model=DeploymentConfig, tags=["Deployment Configs"]) +async def get_deployment_config( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> DeploymentConfig: + try: + return await entity_client.get(DeploymentConfig, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"DeploymentConfig '{name}' not found in workspace '{workspace}'.", + ) from exc + + +@router.delete("/deployment-configs/{name}", status_code=204, tags=["Deployment Configs"]) +async def delete_deployment_config( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> None: + # Best-effort referential check before delete. Entity store has no conditional + # delete API today, so this cannot be fully atomic against concurrent creates. + referencing = await deployment_names_using_config( + entity_client, + workspace=workspace, + config_name=name, + ) + if referencing: + joined = ", ".join(referencing) + raise HTTPException( + status_code=409, + detail=( + f"DeploymentConfig '{name}' is referenced by deployment(s): {joined}. Delete those deployments first." + ), + ) + + try: + await entity_client.delete(DeploymentConfig, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"DeploymentConfig '{name}' not found in workspace '{workspace}'.", + ) from exc diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/deployments.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/deployments.py new file mode 100644 index 0000000000..2515c51088 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/deployments.py @@ -0,0 +1,162 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deployment CRUD routes.""" + +from __future__ import annotations + +import logging +from typing import cast + +from fastapi import APIRouter, Depends, HTTPException, Query +from nemo_deployments_plugin.api.v2.dependencies import get_entity_client +from nemo_deployments_plugin.entities import Deployment, DeploymentConfig, DeploymentStatus +from nemo_deployments_plugin.schema import CreateDeploymentRequest, DeploymentFilter, DeploymentPage +from nemo_platform_plugin.api.filters import make_filter_obj_dep +from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError +from nemo_platform_plugin.filter_ops import ComparisonOperation, FilterOperator +from nemo_platform_plugin.schema import PaginationData + +logger = logging.getLogger(__name__) + +router = APIRouter() + +_deployment_filter_dep = make_filter_obj_dep(DeploymentFilter) + +_VALID_DEPLOYMENT_STATUSES: frozenset[str] = frozenset( + {"PENDING", "STARTING", "READY", "SUCCEEDED", "FAILED", "LOST", "DELETING"} +) + + +def _parse_status_in(status_in: str | None) -> list[DeploymentStatus]: + if not status_in: + return [] + values = [part.strip().upper() for part in status_in.split(",") if part.strip()] + invalid = [value for value in values if value not in _VALID_DEPLOYMENT_STATUSES] + if invalid: + raise HTTPException( + status_code=400, + detail=f"Invalid deployment status values: {', '.join(invalid)}", + ) + return cast(list[DeploymentStatus], values) + + +def _parse_deployment_config_ref(ref: str, default_workspace: str) -> tuple[str, str]: + """Return (config_workspace, config_name) from a bare name or workspace/name ref.""" + if "/" in ref: + config_workspace, config_name = ref.split("/", 1) + if not config_workspace or not config_name: + raise HTTPException( + status_code=400, + detail=f"Invalid deployment_config ref '{ref}'; expected 'name' or 'workspace/name'.", + ) + return config_workspace, config_name + return default_workspace, ref + + +@router.post("/deployments", response_model=Deployment, status_code=201, tags=["Deployments"]) +async def create_deployment( + workspace: str, + body: CreateDeploymentRequest, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Deployment: + config_workspace, config_name = _parse_deployment_config_ref(body.deployment_config, workspace) + try: + await entity_client.get(DeploymentConfig, name=config_name, workspace=config_workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=(f"DeploymentConfig '{config_name}' not found in workspace '{config_workspace}'."), + ) from exc + + deployment = Deployment( + name=body.name, + workspace=workspace, + deployment_config=config_name, + desired_state=body.desired_state, + executor=body.executor, + status="PENDING", + ) + try: + return await entity_client.create(deployment) + except NemoEntityConflictError as exc: + raise HTTPException( + status_code=409, + detail=f"Deployment '{body.name}' already exists in workspace '{workspace}'.", + ) from exc + + +@router.get("/deployments", response_model=DeploymentPage, tags=["Deployments"]) +async def list_deployments( + workspace: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + sort: str = Query(default="-created_at"), + status_in: str | None = Query( + default=None, + description="Comma-separated deployment statuses for bulk reconciler queries.", + ), + filter: DeploymentFilter = Depends(_deployment_filter_dep), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> DeploymentPage: + filter_dict = filter if isinstance(filter, dict) else filter.model_dump(exclude_none=True) + statuses = _parse_status_in(status_in) if status_in else [] + filter_operation = None + if statuses: + filter_operation = ComparisonOperation( + operator=FilterOperator.IN, + field="status", + value=statuses, + ) + result = await entity_client.list( + Deployment, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter_obj=filter_dict or None, + filter_operation=filter_operation, + ) + pagination = PaginationData.model_validate(result.pagination.model_dump()) if result.pagination else None + return DeploymentPage(data=result.data, pagination=pagination, sort=sort, filter=filter) + + +@router.get("/deployments/{name}", response_model=Deployment, tags=["Deployments"]) +async def get_deployment( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Deployment: + try: + return await entity_client.get(Deployment, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Deployment '{name}' not found in workspace '{workspace}'.", + ) from exc + + +@router.delete("/deployments/{name}", status_code=204, tags=["Deployments"]) +async def delete_deployment( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> None: + try: + deployment = await entity_client.get(Deployment, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Deployment '{name}' not found in workspace '{workspace}'.", + ) from exc + + deployment.status = "DELETING" + try: + await entity_client.update(deployment) + except NemoEntityNotFoundError: + logger.info("Deployment already deleted before status update") + except NemoEntityConflictError as exc: + raise HTTPException( + status_code=409, + detail=f"Deployment '{name}' is being modified concurrently.", + ) from exc diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/status.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/status.py new file mode 100644 index 0000000000..32f0864f0f --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/status.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Controller-only status update routes.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from nemo_deployments_plugin.api.v2.dependencies import get_entity_client, require_service_principal +from nemo_deployments_plugin.entities import Deployment, Volume +from nemo_deployments_plugin.schema import UpdateDeploymentStatusRequest, UpdateVolumeStatusRequest +from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError + +router = APIRouter() + + +@router.put("/deployments/{name}/status", response_model=Deployment, tags=["Deployment Status"]) +async def update_deployment_status( + workspace: str, + name: str, + body: UpdateDeploymentStatusRequest, + _: None = Depends(require_service_principal), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Deployment: + try: + deployment = await entity_client.get(Deployment, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Deployment '{name}' not found in workspace '{workspace}'.", + ) from exc + + deployment.status = body.status + deployment.status_message = body.status_message + deployment.exit_code = body.exit_code + deployment.error_details = body.error_details + + try: + return await entity_client.update(deployment) + except NemoEntityConflictError as exc: + raise HTTPException(status_code=409, detail="Concurrent modification.") from exc + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Deployment '{name}' not found in workspace '{workspace}'.", + ) from exc + + +@router.put("/volumes/{name}/status", response_model=Volume, tags=["Volume Status"]) +async def update_volume_status( + workspace: str, + name: str, + body: UpdateVolumeStatusRequest, + _: None = Depends(require_service_principal), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Volume: + try: + volume = await entity_client.get(Volume, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Volume '{name}' not found in workspace '{workspace}'.", + ) from exc + + volume.status = body.status + volume.status_message = body.status_message + volume.error_details = body.error_details + + try: + return await entity_client.update(volume) + except NemoEntityConflictError as exc: + raise HTTPException(status_code=409, detail="Concurrent modification.") from exc + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Volume '{name}' not found in workspace '{workspace}'.", + ) from exc diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/volumes.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/volumes.py new file mode 100644 index 0000000000..29191afd3c --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/volumes.py @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Volume CRUD routes.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from nemo_deployments_plugin.api.v2.dependencies import get_entity_client +from nemo_deployments_plugin.entities import Volume +from nemo_deployments_plugin.references import deployment_config_names_referencing_volume +from nemo_deployments_plugin.schema import CreateVolumeRequest, VolumeFilter, VolumePage +from nemo_platform_plugin.api.filters import make_filter_obj_dep +from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError +from nemo_platform_plugin.schema import PaginationData + +router = APIRouter() + +_volume_filter_dep = make_filter_obj_dep(VolumeFilter) + + +@router.post("/volumes", response_model=Volume, status_code=201, tags=["Volumes"]) +async def create_volume( + workspace: str, + body: CreateVolumeRequest, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Volume: + volume = Volume( + name=body.name, + workspace=workspace, + status="PENDING", + **body.model_dump(exclude={"name"}, exclude_none=True), + ) + try: + return await entity_client.create(volume) + except NemoEntityConflictError as exc: + raise HTTPException( + status_code=409, + detail=f"Volume '{body.name}' already exists in workspace '{workspace}'.", + ) from exc + + +@router.get("/volumes", response_model=VolumePage, tags=["Volumes"]) +async def list_volumes( + workspace: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + sort: str = Query(default="-created_at"), + filter: VolumeFilter = Depends(_volume_filter_dep), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> VolumePage: + filter_dict = filter if isinstance(filter, dict) else filter.model_dump(exclude_none=True) + result = await entity_client.list( + Volume, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter_obj=filter_dict or None, + ) + pagination = PaginationData.model_validate(result.pagination.model_dump()) if result.pagination else None + return VolumePage(data=result.data, pagination=pagination, sort=sort, filter=filter) + + +@router.get("/volumes/{name}", response_model=Volume, tags=["Volumes"]) +async def get_volume( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Volume: + try: + return await entity_client.get(Volume, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Volume '{name}' not found in workspace '{workspace}'.", + ) from exc + + +@router.delete("/volumes/{name}", status_code=204, tags=["Volumes"]) +async def delete_volume( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> None: + referencing = await deployment_config_names_referencing_volume( + entity_client, + workspace=workspace, + volume_name=name, + ) + if referencing: + joined = ", ".join(referencing) + raise HTTPException( + status_code=409, + detail=( + f"Volume '{name}' is referenced by deployment-config(s): {joined}. " + "Remove volume mounts from those configs first." + ), + ) + + try: + await entity_client.delete(Volume, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Volume '{name}' not found in workspace '{workspace}'.", + ) from exc diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/base.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/base.py new file mode 100644 index 0000000000..e87e7aac64 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/base.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deployment backend ABC and status projection types.""" + +from __future__ import annotations + +import abc +from abc import abstractmethod +from dataclasses import dataclass +from typing import Any + +from nemo_deployments_plugin.types import DeploymentStatus, Endpoint, VolumeStatus +from nemo_platform import AsyncNeMoPlatform +from pydantic import BaseModel, Field + + +class BackendStatusUpdate(BaseModel): + """Status projection returned by backends — consumed by the reconciler (758).""" + + status: DeploymentStatus + status_message: str = "" + error_details: dict[str, Any] | None = None + endpoints: list[Endpoint] = Field(default_factory=list) + exit_code: int | None = None + + +class VolumeStatusUpdate(BaseModel): + status: VolumeStatus + status_message: str = "" + error_details: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class LogResult: + lines: list[str] + truncated: bool = False + + +class DeploymentBackend(abc.ABC): + """Abstract substrate backend for deployment and volume lifecycle.""" + + def __init__(self, sdk: AsyncNeMoPlatform, config: dict[str, Any]) -> None: + self._sdk = sdk + self._config = config + self.init() + + def init(self) -> None: + """Optional hook for backend-specific startup.""" + + @abstractmethod + def shutdown(self) -> None: + """Release backend resources.""" + raise NotImplementedError + + @abstractmethod + async def create_deployment( + self, + *, + workspace: str, + name: str, + config_name: str, + labels: dict[str, str], + backend_config: dict[str, Any], + ) -> BackendStatusUpdate: + raise NotImplementedError + + @abstractmethod + async def read_status(self, *, workspace: str, name: str) -> BackendStatusUpdate: + raise NotImplementedError + + @abstractmethod + async def delete_deployment(self, workspace: str, name: str) -> BackendStatusUpdate: + raise NotImplementedError + + @abstractmethod + async def list_managed_deployment_names(self) -> list[str]: + raise NotImplementedError + + @abstractmethod + async def get_logs( + self, + *, + workspace: str, + name: str, + tail: int = 100, + ) -> LogResult: + raise NotImplementedError + + @abstractmethod + async def create_volume( + self, + *, + workspace: str, + name: str, + size: str, + access_modes: list[str], + backend_config: dict[str, Any], + ) -> VolumeStatusUpdate: + raise NotImplementedError + + @abstractmethod + async def read_volume_status(self, *, workspace: str, name: str) -> VolumeStatusUpdate: + raise NotImplementedError + + @abstractmethod + async def delete_volume(self, workspace: str, name: str) -> VolumeStatusUpdate: + raise NotImplementedError diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/registry.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/registry.py new file mode 100644 index 0000000000..05aacd509b --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/registry.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Named executor registry for deployment backends.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Self + +from nemo_deployments_plugin.backends.base import DeploymentBackend +from nemo_platform import AsyncNeMoPlatform + +logger = logging.getLogger(__name__) + +BACKEND_CLASSES: dict[str, type[DeploymentBackend]] = {} +"""Backend type → class map. Populated when docker/k8s backends land (756/757).""" + + +@dataclass(frozen=True) +class ExecutorSpec: + name: str + backend: str + config: dict[str, Any] + + +class ExecutorNotFoundError(KeyError): + """Raised when no executor matches the requested name.""" + + +class UnknownBackendTypeError(KeyError): + """Raised when an executor references an unknown backend type.""" + + +class ExecutorRegistry: + """Maps executor names to configured DeploymentBackend singletons.""" + + def __init__(self, executors: dict[str, DeploymentBackend], *, default_executor: str | None) -> None: + self._executors = executors + self._default_executor = default_executor + + @classmethod + def from_config( + cls, + sdk: AsyncNeMoPlatform, + specs: list[ExecutorSpec], + *, + default_executor: str | None = None, + backend_classes: dict[str, type[DeploymentBackend]] | None = None, + ) -> Self: + classes = backend_classes if backend_classes is not None else BACKEND_CLASSES + if len({spec.name for spec in specs}) != len(specs): + raise ValueError("Duplicate executor names are not allowed.") + executors: dict[str, DeploymentBackend] = {} + try: + for spec in specs: + if spec.backend not in classes: + raise UnknownBackendTypeError(f"Unknown backend type '{spec.backend}' for executor '{spec.name}'.") + executors[spec.name] = classes[spec.backend](sdk, spec.config) + if default_executor and default_executor not in executors: + raise ExecutorNotFoundError(f"default_executor '{default_executor}' is not registered.") + except Exception: + for backend in executors.values(): + backend.shutdown() + raise + return cls(executors, default_executor=default_executor) + + @classmethod + def empty(cls) -> Self: + """Registry with zero executors — valid at scaffold startup.""" + return cls({}, default_executor=None) + + def resolve(self, name: str | None = None) -> DeploymentBackend: + executor_name = name or self._default_executor + if executor_name is None: + raise ExecutorNotFoundError("No executor specified and no default_executor configured.") + if executor_name not in self._executors: + raise ExecutorNotFoundError(f"Executor '{executor_name}' is not registered.") + return self._executors[executor_name] + + def shutdown_all(self) -> None: + for name, backend in self._executors.items(): + logger.debug("Shutting down executor '%s'", name) + backend.shutdown() + + def all_backends(self) -> list[DeploymentBackend]: + return list(self._executors.values()) + + def registered_names(self) -> list[str]: + return list(self._executors.keys()) diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/config.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/config.py new file mode 100644 index 0000000000..e25fec4c6c --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/config.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deployments plugin configuration.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from nemo_platform_plugin.config import NemoConfig +from pydantic import BaseModel, Field + + +class ExecutorConfigEntry(BaseModel): + name: str = Field(description="Unique executor name used by Deployment.executor.") + backend: str = Field(description="Backend type key registered in BACKEND_CLASSES.") + config: dict[str, Any] = Field(default_factory=dict) + + +class DeploymentsConfig(NemoConfig): + plugin_name: ClassVar[str] = "deployments" + plugin_description: ClassVar[str] = "Configuration for the NeMo Platform deployments plugin." + + executors: list[ExecutorConfigEntry] = Field( + default_factory=list, + description="Named executor instances. May be empty at scaffold time.", + ) + default_executor: str | None = Field( + default=None, + description="Fallback executor when Deployment.executor is unset.", + ) diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/constants.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/constants.py new file mode 100644 index 0000000000..903b47ae80 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/constants.py @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared constants for the deployments plugin.""" + +MANAGED_BY_LABEL = "nemo-deployments" +"""Label value backends use to tag substrate resources for orphan cleanup.""" + +ENTITY_TYPE_DEPLOYMENT_CONFIG = "deployments_deployment_config" +ENTITY_TYPE_DEPLOYMENT = "deployments_deployment" +ENTITY_TYPE_VOLUME = "deployments_volume" diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py new file mode 100644 index 0000000000..be7175f748 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py @@ -0,0 +1,245 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deployment plugin entity definitions and supporting Pydantic types.""" + +from __future__ import annotations + +from typing import Any, Literal + +from nemo_deployments_plugin.constants import ( + ENTITY_TYPE_DEPLOYMENT, + ENTITY_TYPE_DEPLOYMENT_CONFIG, + ENTITY_TYPE_VOLUME, +) +from nemo_deployments_plugin.types import ( + AccessMode, + DeploymentStatus, + DesiredState, + DriftRecoveryAction, + Endpoint, + RestartPolicy, + VolumeStatus, +) +from nemo_platform_plugin.entity import NemoEntity +from pydantic import BaseModel, Field + + +class EnvVar(BaseModel): + name: str + value: str | None = None + value_from: dict[str, Any] | None = Field(default=None, alias="valueFrom") + + model_config = {"populate_by_name": True} + + +class ContainerPort(BaseModel): + name: str | None = None + container_port: int = Field(alias="containerPort") + protocol: Literal["TCP", "UDP"] = "TCP" + + model_config = {"populate_by_name": True} + + +class ResourceRequirements(BaseModel): + limits: dict[str, str] = Field(default_factory=dict) + requests: dict[str, str] = Field(default_factory=dict) + + +class VolumeMount(BaseModel): + name: str + mount_path: str = Field(alias="mountPath") + read_only: bool = Field(default=False, alias="readOnly") + sub_path: str | None = Field(default=None, alias="subPath") + + model_config = {"populate_by_name": True} + + +class ExecAction(BaseModel): + command: list[str] = Field(default_factory=list) + + +class HTTPGetAction(BaseModel): + path: str = "/" + port: int | str = 8080 + scheme: Literal["HTTP", "HTTPS"] = "HTTP" + + +class TCPSocketAction(BaseModel): + port: int | str + + +class Probe(BaseModel): + exec_action: ExecAction | None = Field(default=None, alias="exec") + http_get: HTTPGetAction | None = Field(default=None, alias="httpGet") + tcp_socket: TCPSocketAction | None = Field(default=None, alias="tcpSocket") + initial_delay_seconds: int = Field(default=0, alias="initialDelaySeconds") + period_seconds: int = Field(default=10, alias="periodSeconds") + timeout_seconds: int = Field(default=1, alias="timeoutSeconds") + failure_threshold: int = Field(default=3, alias="failureThreshold") + + model_config = {"populate_by_name": True} + + +class Container(BaseModel): + name: str + image: str + command: list[str] = Field(default_factory=list) + args: list[str] = Field(default_factory=list) + env: list[EnvVar] = Field(default_factory=list) + ports: list[ContainerPort] = Field(default_factory=list) + resources: ResourceRequirements = Field(default_factory=ResourceRequirements) + volume_mounts: list[VolumeMount] = Field(default_factory=list, alias="volumeMounts") + liveness_probe: Probe | None = Field(default=None, alias="livenessProbe") + readiness_probe: Probe | None = Field(default=None, alias="readinessProbe") + + model_config = {"populate_by_name": True} + + +class ConfigFile(BaseModel): + path: str + content: str + mode: int = 0o644 + + +class Toleration(BaseModel): + key: str | None = None + operator: Literal["Equal", "Exists"] = "Equal" + value: str | None = None + effect: Literal["NoSchedule", "PreferNoSchedule", "NoExecute"] | None = None + toleration_seconds: int | None = Field(default=None, alias="tolerationSeconds") + + model_config = {"populate_by_name": True} + + +class LabelSelector(BaseModel): + match_labels: dict[str, str] = Field(default_factory=dict, alias="matchLabels") + + model_config = {"populate_by_name": True} + + +class LocalObjectReference(BaseModel): + name: str + + +class PodSecurityContext(BaseModel): + run_as_user: int | None = Field(default=None, alias="runAsUser") + run_as_group: int | None = Field(default=None, alias="runAsGroup") + fs_group: int | None = Field(default=None, alias="fsGroup") + + model_config = {"populate_by_name": True} + + +class Affinity(BaseModel): + node_affinity: dict[str, Any] | None = Field(default=None, alias="nodeAffinity") + pod_affinity: dict[str, Any] | None = Field(default=None, alias="podAffinity") + pod_anti_affinity: dict[str, Any] | None = Field(default=None, alias="podAntiAffinity") + + model_config = {"populate_by_name": True} + + +class DockerDeploymentConfig(BaseModel): + port_range_start: int = 9000 + port_range_end: int = 9100 + network: str | None = None + + +class K8sDeploymentConfig(BaseModel): + namespace: str | None = None + service_account: str | None = Field(default=None, alias="serviceAccount") + tolerations: list[Toleration] = Field(default_factory=list) + affinity: Affinity | None = None + security_context: PodSecurityContext | None = Field(default=None, alias="securityContext") + + model_config = {"populate_by_name": True} + + +class DeploymentBackendConfig(BaseModel): + docker: DockerDeploymentConfig | None = None + k8s: K8sDeploymentConfig | None = None + + +class DockerVolumeConfig(BaseModel): + driver: str = "local" + mount_point: str | None = None + + +class K8sVolumeConfig(BaseModel): + storage_class: str | None = Field(default=None, alias="storageClass") + namespace: str | None = None + + model_config = {"populate_by_name": True} + + +class VolumeBackendConfig(BaseModel): + docker: DockerVolumeConfig | None = None + k8s: K8sVolumeConfig | None = None + + +class DriftRecoveryPolicy(BaseModel): + action: DriftRecoveryAction = "recreate" + + +class Prerequisite(BaseModel): + deployment_name: str = Field( + description=( + "Name of another DeploymentConfig in the same workspace that must reach " + "a ready terminal state before this config's deployment may start." + ), + ) + + +class StatusEvent(BaseModel): + status: DeploymentStatus + message: str = "" + timestamp: str = "" + + +class DeploymentConfig(NemoEntity, entity_type=ENTITY_TYPE_DEPLOYMENT_CONFIG): + """Immutable PodSpec-shaped deployment template.""" + + containers: list[Container] = Field(default_factory=list) + init_containers: list[Container] = Field(default_factory=list, alias="initContainers") + volume_mounts: list[VolumeMount] = Field(default_factory=list, alias="volumeMounts") + config_files: list[ConfigFile] = Field(default_factory=list, alias="configFiles") + restart_policy: RestartPolicy = Field(default="Always", alias="restartPolicy") + backoff_limit: int = Field(default=6, alias="backoffLimit") + prerequisites: list[Prerequisite] = Field(default_factory=list) + drift_recovery: DriftRecoveryPolicy = Field(default_factory=DriftRecoveryPolicy, alias="driftRecovery") + labels: dict[str, str] = Field(default_factory=dict) + backend_config: DeploymentBackendConfig = Field(default_factory=DeploymentBackendConfig, alias="backendConfig") + + model_config = {"populate_by_name": True} + + +class Deployment(NemoEntity, entity_type=ENTITY_TYPE_DEPLOYMENT): + """Desired and observed deployment state.""" + + deployment_config: str = Field(description="Name of the DeploymentConfig entity.") + desired_state: DesiredState = Field(default="READY") + executor: str | None = Field( + default=None, + description="Named executor registry entry; falls back to plugin default_executor.", + ) + status: DeploymentStatus = Field(default="PENDING") + status_message: str = Field(default="") + endpoints: list[Endpoint] = Field(default_factory=list) + exit_code: int | None = None + error_details: dict[str, Any] | None = None + status_history: list[StatusEvent] = Field(default_factory=list) + + # Reconciler (758) enforces restart_policy vs terminal status on DeploymentConfig: + # Never → SUCCEEDED terminal; Always/OnFailure → READY while running. + + +class Volume(NemoEntity, entity_type=ENTITY_TYPE_VOLUME): + """Persistent volume request and observed state.""" + + size: str = Field(default="1Gi", description="Requested storage size (Kubernetes quantity).") + access_modes: list[AccessMode] = Field(default_factory=lambda: ["ReadWriteOnce"]) + backend_config: VolumeBackendConfig = Field(default_factory=VolumeBackendConfig, alias="backendConfig") + status: VolumeStatus = Field(default="PENDING") + status_message: str = Field(default="") + error_details: dict[str, Any] | None = None + + model_config = {"populate_by_name": True} diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/references.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/references.py new file mode 100644 index 0000000000..cf5e67abcb --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/references.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Referential integrity checks before destructive operations.""" + +from __future__ import annotations + +from nemo_deployments_plugin.entities import Container, Deployment, DeploymentConfig +from nemo_platform_plugin.entity_client import NemoEntitiesClient + + +async def deployment_names_using_config( + entity_client: NemoEntitiesClient, + *, + workspace: str, + config_name: str, +) -> list[str]: + """Return deployment names in workspace that reference the given config.""" + page = 1 + names: list[str] = [] + while True: + result = await entity_client.list( + Deployment, + workspace=workspace, + page=page, + page_size=100, + filter_obj={"deployment_config": config_name}, + ) + names.extend(dep.name for dep in result.data) + if result.pagination is None or page >= result.pagination.total_pages: + break + page += 1 + return names + + +def _container_mounts_volume(container: Container, volume_name: str) -> bool: + return any(mount.name == volume_name for mount in container.volume_mounts) + + +def _config_references_volume(config: DeploymentConfig, volume_name: str) -> bool: + if any(mount.name == volume_name for mount in config.volume_mounts): + return True + for container in (*config.containers, *config.init_containers): + if _container_mounts_volume(container, volume_name): + return True + return False + + +async def deployment_config_names_referencing_volume( + entity_client: NemoEntitiesClient, + *, + workspace: str, + volume_name: str, +) -> list[str]: + """Return deployment-config names in workspace whose mounts reference the volume.""" + page = 1 + names: list[str] = [] + while True: + result = await entity_client.list( + DeploymentConfig, + workspace=workspace, + page=page, + page_size=100, + ) + for config in result.data: + if _config_references_volume(config, volume_name): + names.append(config.name) + if result.pagination is None or page >= result.pagination.total_pages: + break + page += 1 + return names diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/schema.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/schema.py new file mode 100644 index 0000000000..ede7d82bbf --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/schema.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deployments plugin API schema definitions — request bodies and filters.""" + +from __future__ import annotations + +from typing import Any + +from nemo_deployments_plugin.entities import ( + AccessMode, + ConfigFile, + Container, + Deployment, + DeploymentBackendConfig, + DeploymentConfig, + DeploymentStatus, + DesiredState, + DriftRecoveryPolicy, + Prerequisite, + RestartPolicy, + Volume, + VolumeBackendConfig, + VolumeMount, + VolumeStatus, +) +from nemo_platform_plugin.schema import NemoFilter, NemoListResponse +from pydantic import BaseModel, Field + + +class CreateDeploymentConfigRequest(BaseModel): + name: str + containers: list[Container] = Field(default_factory=list) + init_containers: list[Container] = Field(default_factory=list) + volume_mounts: list[VolumeMount] = Field(default_factory=list) + config_files: list[ConfigFile] = Field(default_factory=list) + restart_policy: RestartPolicy = "Always" + backoff_limit: int = 6 + prerequisites: list[Prerequisite] = Field(default_factory=list) + drift_recovery: DriftRecoveryPolicy | None = None + labels: dict[str, str] = Field(default_factory=dict) + backend_config: DeploymentBackendConfig = Field(default_factory=DeploymentBackendConfig) + + +class CreateDeploymentRequest(BaseModel): + name: str + deployment_config: str = Field( + description="DeploymentConfig name in this workspace, or workspace/name for cross-workspace refs.", + ) + desired_state: DesiredState = "READY" + executor: str | None = None + + +class CreateVolumeRequest(BaseModel): + name: str + size: str = "1Gi" + access_modes: list[AccessMode] = Field(default_factory=lambda: ["ReadWriteOnce"]) + backend_config: VolumeBackendConfig = Field(default_factory=VolumeBackendConfig) + + +class UpdateDeploymentStatusRequest(BaseModel): + status: DeploymentStatus + status_message: str = "" + exit_code: int | None = None + error_details: dict[str, Any] | None = None + + +class UpdateVolumeStatusRequest(BaseModel): + status: VolumeStatus + status_message: str = "" + error_details: dict[str, Any] | None = None + + +class DeploymentConfigFilter(NemoFilter): + restart_policy: RestartPolicy | None = None + + +class DeploymentFilter(NemoFilter): + deployment_config: str | None = None + desired_state: DesiredState | None = None + executor: str | None = None + status: DeploymentStatus | None = None + + +class VolumeFilter(NemoFilter): + status: VolumeStatus | None = None + + +DeploymentConfigPage = NemoListResponse[DeploymentConfig] +DeploymentPage = NemoListResponse[Deployment] +VolumePage = NemoListResponse[Volume] diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/service.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/service.py new file mode 100644 index 0000000000..d5ab102a04 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/service.py @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deployments plugin service registration.""" + +from __future__ import annotations + +import logging +from typing import ClassVar + +from nemo_deployments_plugin.backends.registry import ExecutorRegistry, ExecutorSpec +from nemo_deployments_plugin.config import DeploymentsConfig +from nemo_platform import AsyncNeMoPlatform +from nemo_platform_plugin.authz import AuthzContribution, AuthzEndpointMethod +from nemo_platform_plugin.sdk_provider import get_async_platform_sdk +from nemo_platform_plugin.service import NemoService, RouterSpec + +logger = logging.getLogger(__name__) + +_SERVICE_NAME = "deployments" +_READ_SCOPES = [f"{_SERVICE_NAME}:read", "platform:read"] +_WRITE_SCOPES = [f"{_SERVICE_NAME}:write", "platform:write"] + + +def _read_method(permission: str) -> AuthzEndpointMethod: + return AuthzEndpointMethod(permissions=[permission], scopes=list(_READ_SCOPES)) + + +def _write_method(permission: str) -> AuthzEndpointMethod: + return AuthzEndpointMethod(permissions=[permission], scopes=list(_WRITE_SCOPES)) + + +def _read_methods(permission: str) -> dict[str, AuthzEndpointMethod]: + return {method: _read_method(permission) for method in ("get", "head")} + + +class DeploymentsService(NemoService): + """HTTP service for deployment configs, deployments, volumes, and controller status.""" + + name: ClassVar[str] = "deployments" + dependencies: ClassVar[list[str]] = ["entities", "auth"] + + def __init__(self) -> None: + self._executor_registry: ExecutorRegistry | None = None + + @property + def executor_registry(self) -> ExecutorRegistry: + if self._executor_registry is None: + self._executor_registry = ExecutorRegistry.empty() + return self._executor_registry + + @classmethod + def get_authz_contribution(cls) -> AuthzContribution: + """Authorization policy for deployments plugin routes.""" + base = f"/apis/{cls.name}/v2/workspaces/{{workspace}}" + permissions: dict[str, str] = {} + endpoints: dict[str, dict[str, AuthzEndpointMethod]] = {} + + for resource, path_segment in ( + ("deployment-configs", "deployment-configs"), + ("deployments", "deployments"), + ("volumes", "volumes"), + ): + create_perm = f"{cls.name}.{resource}.create" + list_perm = f"{cls.name}.{resource}.list" + read_perm = f"{cls.name}.{resource}.read" + delete_perm = f"{cls.name}.{resource}.delete" + permissions.update( + { + create_perm: f"Create {cls.name} {resource}", + list_perm: f"List {cls.name} {resource}", + read_perm: f"Read {cls.name} {resource}", + delete_perm: f"Delete {cls.name} {resource}", + } + ) + endpoints[f"{base}/{path_segment}"] = { + **_read_methods(list_perm), + "post": _write_method(create_perm), + } + endpoints[f"{base}/{path_segment}/{{name}}"] = { + "delete": _write_method(delete_perm), + **_read_methods(read_perm), + } + + deployment_status_perm = f"{cls.name}.deployments.status.update" + volume_status_perm = f"{cls.name}.volumes.status.update" + permissions[deployment_status_perm] = "Update deployment observed status (controller)" + permissions[volume_status_perm] = "Update volume observed status (controller)" + endpoints[f"{base}/deployments/{{name}}/status"] = { + "put": _write_method(deployment_status_perm), + } + endpoints[f"{base}/volumes/{{name}}/status"] = { + "put": _write_method(volume_status_perm), + } + + return AuthzContribution(permissions=permissions, endpoints=endpoints) + + def get_routers(self) -> list[RouterSpec]: + from nemo_deployments_plugin.api.v2 import ( + deployment_configs, + deployments, + status, + volumes, + ) + + prefix = "/v2/workspaces/{workspace}" + return [ + RouterSpec( + deployment_configs.router, + tag="Deployment Configs", + description="Immutable deployment templates", + prefix=prefix, + ), + RouterSpec( + deployments.router, + tag="Deployments", + description="Deployment lifecycle", + prefix=prefix, + ), + RouterSpec( + volumes.router, + tag="Volumes", + description="Volume lifecycle", + prefix=prefix, + ), + RouterSpec( + status.router, + tag="Deployment Status", + description="Controller-only status projection", + prefix=prefix, + ), + ] + + async def on_startup(self) -> None: + config = DeploymentsConfig.get() + sdk: AsyncNeMoPlatform = get_async_platform_sdk(as_service="deployments", internal=True) + specs = [ExecutorSpec(name=e.name, backend=e.backend, config=e.config) for e in config.executors] + if specs: + self._executor_registry = ExecutorRegistry.from_config( + sdk, + specs, + default_executor=config.default_executor, + ) + else: + self._executor_registry = ExecutorRegistry.empty() + if config.default_executor: + logger.warning( + "default_executor '%s' is configured but no executors are registered.", + config.default_executor, + ) + logger.info("Deployments plugin started with zero registered executors.") + + async def on_shutdown(self) -> None: + if self._executor_registry is not None: + self._executor_registry.shutdown_all() diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/types.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/types.py new file mode 100644 index 0000000000..dbcc67a073 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/types.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared deployment status and endpoint types (no entity imports).""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +DeploymentStatus = Literal[ + "PENDING", + "STARTING", + "READY", + "SUCCEEDED", + "FAILED", + "LOST", + "DELETING", +] +VolumeStatus = Literal["PENDING", "BOUND", "RELEASED", "FAILED"] +DesiredState = Literal["READY", "STOPPED"] +RestartPolicy = Literal["Always", "OnFailure", "Never"] +AccessMode = Literal["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany"] +DriftRecoveryAction = Literal["recreate", "ignore"] + + +class Endpoint(BaseModel): + name: str + url: str + protocol: Literal["http", "https", "grpc", "tcp"] = "http" diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/validation.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/validation.py new file mode 100644 index 0000000000..dd45549820 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/validation.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Prerequisite graph validation for DeploymentConfig entities.""" + +from __future__ import annotations + +from collections import defaultdict + +from nemo_deployments_plugin.entities import DeploymentConfig, Prerequisite + + +class PrerequisiteCycleError(ValueError): + """Raised when deployment prerequisites contain a cycle.""" + + +def detect_prerequisite_cycle( + *, + config_name: str, + prerequisites: list[str], + existing: dict[str, list[str]], +) -> None: + """Detect cycles in the prerequisite graph within a workspace.""" + graph: dict[str, list[str]] = {name: list(deps) for name, deps in existing.items()} + graph[config_name] = list(prerequisites) + + visited: set[str] = set() + stack: set[str] = set() + + def dfs(node: str) -> None: + if node in stack: + raise PrerequisiteCycleError(f"Prerequisite cycle detected involving deployment config '{node}'.") + if node in visited: + return + visited.add(node) + stack.add(node) + for dep in graph.get(node, []): + dfs(dep) + stack.remove(node) + + for node in graph: + dfs(node) + + +def prerequisite_names(prerequisites: list[Prerequisite]) -> list[str]: + return [prerequisite.deployment_name for prerequisite in prerequisites] + + +def build_existing_prerequisite_map(configs: list[DeploymentConfig]) -> dict[str, list[str]]: + graph: dict[str, list[str]] = defaultdict(list) + for cfg in configs: + graph[cfg.name] = prerequisite_names(cfg.prerequisites) + return dict(graph) diff --git a/plugins/nemo-deployments/tests/unit/helpers.py b/plugins/nemo-deployments/tests/unit/helpers.py new file mode 100644 index 0000000000..90ca2a1572 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/helpers.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from unittest.mock import MagicMock + +from nemo_deployments_plugin.entities import Container, Deployment, DeploymentConfig, Volume +from nemo_platform_plugin.entity_client import NemoPaginationInfo + +NOW = datetime.now(timezone.utc) + + +def make_deployment_config(name: str = "cfg1", workspace: str = "default") -> DeploymentConfig: + cfg = DeploymentConfig( + name=name, + workspace=workspace, + containers=[Container(name="main", image="nginx")], + ) + cfg._id = f"id-{name}" + cfg._created_at = NOW + return cfg + + +def make_deployment(name: str = "dep1", workspace: str = "default") -> Deployment: + dep = Deployment(name=name, workspace=workspace, deployment_config="cfg1", status="PENDING") + dep._id = f"id-{name}" + dep._created_at = NOW + return dep + + +def make_volume(name: str = "vol1", workspace: str = "default") -> Volume: + vol = Volume(name=name, workspace=workspace) + vol._id = f"id-{name}" + vol._created_at = NOW + return vol + + +def list_response(items: list[Any]) -> MagicMock: + resp = MagicMock() + resp.data = items + resp.pagination = NemoPaginationInfo( + page=1, + page_size=20, + current_page_size=len(items), + total_pages=1, + total_results=len(items), + ) + return resp diff --git a/plugins/nemo-deployments/tests/unit/test_api_deployment_configs.py b/plugins/nemo-deployments/tests/unit/test_api_deployment_configs.py new file mode 100644 index 0000000000..7c514aae7c --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_api_deployment_configs.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from helpers import list_response, make_deployment, make_deployment_config +from nemo_deployments_plugin.api.v2 import deployment_configs as configs_module +from nemo_deployments_plugin.api.v2.dependencies import get_entity_client +from nemo_deployments_plugin.entities import Prerequisite +from nemo_platform_plugin.entity_client import NemoEntityConflictError, NemoEntityNotFoundError + + +@pytest.fixture +def mock_entity_client() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def client(mock_entity_client: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router( + configs_module.router, + prefix="/apis/deployments/v2/workspaces/{workspace}", + ) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +def test_create_deployment_config_201(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.list.return_value = list_response([]) + mock_entity_client.create.return_value = make_deployment_config("cfg1") + resp = client.post( + "/apis/deployments/v2/workspaces/default/deployment-configs", + json={"name": "cfg1", "containers": [{"name": "main", "image": "nginx"}]}, + ) + assert resp.status_code == 201 + assert resp.json()["name"] == "cfg1" + + +def test_create_deployment_config_cycle_400(client: TestClient, mock_entity_client: AsyncMock) -> None: + a = make_deployment_config("a") + b = make_deployment_config("b") + b.prerequisites = [Prerequisite(deployment_name="a")] + mock_entity_client.list.return_value = list_response([a, b]) + resp = client.post( + "/apis/deployments/v2/workspaces/default/deployment-configs", + json={"name": "a", "prerequisites": [{"deployment_name": "b"}]}, + ) + assert resp.status_code == 400 + assert "cycle" in resp.json()["detail"].lower() + + +def test_get_deployment_config_404(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.side_effect = NemoEntityNotFoundError("missing") + resp = client.get("/apis/deployments/v2/workspaces/default/deployment-configs/missing") + assert resp.status_code == 404 + + +def test_delete_deployment_config_204(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.list.return_value = list_response([]) + resp = client.delete("/apis/deployments/v2/workspaces/default/deployment-configs/cfg1") + assert resp.status_code == 204 + + +def test_delete_deployment_config_409_when_referenced(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.list.return_value = list_response([make_deployment("dep1")]) + resp = client.delete("/apis/deployments/v2/workspaces/default/deployment-configs/cfg1") + assert resp.status_code == 409 + assert "referenced" in resp.json()["detail"].lower() + mock_entity_client.delete.assert_not_awaited() + + +def test_create_deployment_config_409(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.list.return_value = list_response([]) + mock_entity_client.create.side_effect = NemoEntityConflictError("exists") + resp = client.post( + "/apis/deployments/v2/workspaces/default/deployment-configs", + json={"name": "cfg1"}, + ) + assert resp.status_code == 409 diff --git a/plugins/nemo-deployments/tests/unit/test_api_deployments.py b/plugins/nemo-deployments/tests/unit/test_api_deployments.py new file mode 100644 index 0000000000..7a632a2bff --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_api_deployments.py @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from helpers import list_response, make_deployment, make_deployment_config +from nemo_deployments_plugin.api.v2 import deployments as deployments_module +from nemo_deployments_plugin.api.v2.dependencies import get_entity_client +from nemo_deployments_plugin.entities import DeploymentConfig +from nemo_platform_plugin.entity_client import NemoEntityConflictError, NemoEntityNotFoundError + + +@pytest.fixture +def mock_entity_client() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def client(mock_entity_client: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router( + deployments_module.router, + prefix="/apis/deployments/v2/workspaces/{workspace}", + ) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +def test_create_deployment_validates_config(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.side_effect = NemoEntityNotFoundError("missing") + resp = client.post( + "/apis/deployments/v2/workspaces/default/deployments", + json={"name": "dep1", "deployment_config": "missing"}, + ) + assert resp.status_code == 404 + + +def test_create_deployment_201(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.return_value = make_deployment_config() + mock_entity_client.create.return_value = make_deployment() + resp = client.post( + "/apis/deployments/v2/workspaces/default/deployments", + json={"name": "dep1", "deployment_config": "cfg1"}, + ) + assert resp.status_code == 201 + assert resp.json()["status"] == "PENDING" + + +def test_create_deployment_accepts_workspace_qualified_config_ref( + client: TestClient, mock_entity_client: AsyncMock +) -> None: + mock_entity_client.get.return_value = make_deployment_config("cfg1", workspace="other") + mock_entity_client.create.return_value = make_deployment() + resp = client.post( + "/apis/deployments/v2/workspaces/default/deployments", + json={"name": "dep1", "deployment_config": "other/cfg1"}, + ) + assert resp.status_code == 201 + mock_entity_client.get.assert_awaited_once_with(DeploymentConfig, name="cfg1", workspace="other") + created = mock_entity_client.create.await_args.args[0] + assert created.deployment_config == "cfg1" + + +def test_create_deployment_rejects_malformed_config_ref_400(client: TestClient) -> None: + resp = client.post( + "/apis/deployments/v2/workspaces/default/deployments", + json={"name": "dep1", "deployment_config": "/cfg1"}, + ) + assert resp.status_code == 400 + + +def test_list_deployments_status_in(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.list.return_value = list_response([make_deployment()]) + resp = client.get( + "/apis/deployments/v2/workspaces/default/deployments", + params={"status_in": "pending,starting"}, + ) + assert resp.status_code == 200 + assert len(resp.json()["data"]) == 1 + call_kwargs = mock_entity_client.list.await_args.kwargs + assert call_kwargs["filter_operation"].operator.value == "$in" + assert call_kwargs["filter_operation"].field == "status" + + +def test_list_deployments_invalid_status_in_400(client: TestClient) -> None: + resp = client.get( + "/apis/deployments/v2/workspaces/default/deployments", + params={"status_in": "banana"}, + ) + assert resp.status_code == 400 + + +def test_delete_deployment_marks_deleting(client: TestClient, mock_entity_client: AsyncMock) -> None: + deployment = make_deployment() + mock_entity_client.get.return_value = deployment + resp = client.delete("/apis/deployments/v2/workspaces/default/deployments/dep1") + assert resp.status_code == 204 + mock_entity_client.update.assert_awaited_once() + updated = mock_entity_client.update.await_args.args[0] + assert updated.status == "DELETING" + + +def test_delete_deployment_conflict_409(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.return_value = make_deployment() + mock_entity_client.update.side_effect = NemoEntityConflictError("conflict") + resp = client.delete("/apis/deployments/v2/workspaces/default/deployments/dep1") + assert resp.status_code == 409 diff --git a/plugins/nemo-deployments/tests/unit/test_api_status.py b/plugins/nemo-deployments/tests/unit/test_api_status.py new file mode 100644 index 0000000000..e105e67503 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_api_status.py @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from helpers import make_deployment, make_volume +from nemo_deployments_plugin.api.v2 import status as status_module +from nemo_deployments_plugin.api.v2.dependencies import get_entity_client +from nemo_platform_plugin.entity_client import NemoEntityNotFoundError + +_SERVICE_HEADERS = {"X-NMP-Principal-Id": "service:deployments"} +_USER_HEADERS = {"X-NMP-Principal-Id": "user@example.com"} + + +@pytest.fixture +def mock_entity_client() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def client(mock_entity_client: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router( + status_module.router, + prefix="/apis/deployments/v2/workspaces/{workspace}", + ) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +def test_status_put_rejects_user(client: TestClient) -> None: + resp = client.put( + "/apis/deployments/v2/workspaces/default/deployments/dep1/status", + json={"status": "READY"}, + headers=_USER_HEADERS, + ) + assert resp.status_code == 403 + + +def test_status_put_ignores_on_behalf_of_for_auth(client: TestClient) -> None: + resp = client.put( + "/apis/deployments/v2/workspaces/default/deployments/dep1/status", + json={"status": "READY"}, + headers={ + "X-NMP-Principal-Id": "user@example.com", + "X-NMP-Principal-On-Behalf-Of": "service:deployments", + }, + ) + assert resp.status_code == 403 + + +def test_status_put_accepts_service_principal(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.return_value = make_deployment() + mock_entity_client.update.return_value = make_deployment() + resp = client.put( + "/apis/deployments/v2/workspaces/default/deployments/dep1/status", + json={"status": "READY", "status_message": "up"}, + headers=_SERVICE_HEADERS, + ) + assert resp.status_code == 200 + + +def test_volume_status_put_accepts_service_principal(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.return_value = make_volume() + mock_entity_client.update.return_value = make_volume() + resp = client.put( + "/apis/deployments/v2/workspaces/default/volumes/vol1/status", + json={"status": "BOUND"}, + headers=_SERVICE_HEADERS, + ) + assert resp.status_code == 200 + + +def test_status_put_404(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.side_effect = NemoEntityNotFoundError("missing") + resp = client.put( + "/apis/deployments/v2/workspaces/default/deployments/missing/status", + json={"status": "READY"}, + headers=_SERVICE_HEADERS, + ) + assert resp.status_code == 404 diff --git a/plugins/nemo-deployments/tests/unit/test_api_volumes.py b/plugins/nemo-deployments/tests/unit/test_api_volumes.py new file mode 100644 index 0000000000..f07a3f02b2 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_api_volumes.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from helpers import list_response, make_deployment_config, make_volume +from nemo_deployments_plugin.api.v2 import volumes as volumes_module +from nemo_deployments_plugin.api.v2.dependencies import get_entity_client +from nemo_deployments_plugin.entities import Container, VolumeMount + + +@pytest.fixture +def mock_entity_client() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def client(mock_entity_client: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router( + volumes_module.router, + prefix="/apis/deployments/v2/workspaces/{workspace}", + ) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +def test_create_volume_201(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.create.return_value = make_volume() + resp = client.post( + "/apis/deployments/v2/workspaces/default/volumes", + json={"name": "vol1", "size": "5Gi"}, + ) + assert resp.status_code == 201 + assert resp.json()["status"] == "PENDING" + created = mock_entity_client.create.await_args.args[0] + assert created.name == "vol1" + assert created.size == "5Gi" + assert created.workspace == "default" + + +def test_list_volumes_200(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.list.return_value = list_response([make_volume()]) + resp = client.get("/apis/deployments/v2/workspaces/default/volumes") + assert resp.status_code == 200 + assert len(resp.json()["data"]) == 1 + + +def test_delete_volume_204(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.list.return_value = list_response([]) + resp = client.delete("/apis/deployments/v2/workspaces/default/volumes/vol1") + assert resp.status_code == 204 + + +def test_delete_volume_409_when_referenced(client: TestClient, mock_entity_client: AsyncMock) -> None: + cfg = make_deployment_config("cfg1") + cfg.containers = [ + Container( + name="main", + image="nginx", + volumeMounts=[VolumeMount(name="vol1", mountPath="/data")], + ) + ] + mock_entity_client.list.return_value = list_response([cfg]) + resp = client.delete("/apis/deployments/v2/workspaces/default/volumes/vol1") + assert resp.status_code == 409 + assert "referenced" in resp.json()["detail"].lower() + mock_entity_client.delete.assert_not_awaited() diff --git a/plugins/nemo-deployments/tests/unit/test_entities.py b/plugins/nemo-deployments/tests/unit/test_entities.py new file mode 100644 index 0000000000..3418820832 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_entities.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import pytest +from nemo_deployments_plugin.entities import Container, Deployment, DeploymentConfig, Volume +from nemo_deployments_plugin.validation import PrerequisiteCycleError, detect_prerequisite_cycle +from pydantic import ValidationError + + +def test_deployment_defaults_to_pending() -> None: + dep = Deployment(name="d1", workspace="default", deployment_config="cfg") + assert dep.status == "PENDING" + assert dep.desired_state == "READY" + + +def test_deployment_config_requires_containers_shape() -> None: + cfg = DeploymentConfig( + name="cfg", + workspace="default", + containers=[Container(name="main", image="nginx:latest")], + ) + assert cfg.containers[0].image == "nginx:latest" + + +def test_volume_default_status_pending() -> None: + vol = Volume(name="v1", workspace="default") + assert vol.status == "PENDING" + assert vol.size == "1Gi" + + +def test_prerequisite_cycle_detected() -> None: + with pytest.raises(PrerequisiteCycleError): + detect_prerequisite_cycle( + config_name="c", + prerequisites=["a"], + existing={"a": ["b"], "b": ["c"]}, + ) + + +def test_invalid_deployment_status_rejected() -> None: + with pytest.raises(ValidationError): + Deployment.model_validate( + { + "name": "d1", + "workspace": "default", + "deployment_config": "cfg", + "status": "not-a-status", + } + ) diff --git a/plugins/nemo-deployments/tests/unit/test_prerequisite_validation.py b/plugins/nemo-deployments/tests/unit/test_prerequisite_validation.py new file mode 100644 index 0000000000..5954b6854f --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_prerequisite_validation.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import pytest +from nemo_deployments_plugin.validation import PrerequisiteCycleError, detect_prerequisite_cycle + + +def test_linear_prerequisites_ok() -> None: + detect_prerequisite_cycle( + config_name="c", + prerequisites=["b"], + existing={"a": [], "b": ["a"]}, + ) + + +def test_self_cycle_rejected() -> None: + with pytest.raises(PrerequisiteCycleError, match="cycle"): + detect_prerequisite_cycle( + config_name="a", + prerequisites=["a"], + existing={}, + ) + + +def test_three_node_cycle_rejected() -> None: + with pytest.raises(PrerequisiteCycleError): + detect_prerequisite_cycle( + config_name="a", + prerequisites=["c"], + existing={"b": ["a"], "c": ["b"]}, + ) diff --git a/plugins/nemo-deployments/tests/unit/test_references.py b/plugins/nemo-deployments/tests/unit/test_references.py new file mode 100644 index 0000000000..2647360782 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_references.py @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from helpers import list_response, make_deployment, make_deployment_config +from nemo_deployments_plugin.entities import Container, VolumeMount +from nemo_deployments_plugin.references import ( + deployment_config_names_referencing_volume, + deployment_names_using_config, +) + + +@pytest.mark.asyncio +async def test_deployment_names_using_config() -> None: + client = AsyncMock() + client.list.return_value = list_response([make_deployment("dep1")]) + names = await deployment_names_using_config(client, workspace="default", config_name="cfg1") + assert names == ["dep1"] + client.list.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_deployment_config_names_referencing_volume() -> None: + cfg = make_deployment_config("cfg1") + cfg.volume_mounts = [VolumeMount(name="vol1", mountPath="/data")] + client = AsyncMock() + client.list.return_value = list_response([cfg]) + names = await deployment_config_names_referencing_volume(client, workspace="default", volume_name="vol1") + assert names == ["cfg1"] + + +@pytest.mark.asyncio +async def test_deployment_config_names_referencing_volume_via_container_mount() -> None: + cfg = make_deployment_config("cfg1") + cfg.containers = [ + Container( + name="main", + image="nginx", + volumeMounts=[VolumeMount(name="vol1", mountPath="/data")], + ) + ] + client = AsyncMock() + client.list.return_value = list_response([cfg]) + names = await deployment_config_names_referencing_volume(client, workspace="default", volume_name="vol1") + assert names == ["cfg1"] diff --git a/plugins/nemo-deployments/tests/unit/test_registry.py b/plugins/nemo-deployments/tests/unit/test_registry.py new file mode 100644 index 0000000000..8bd6fe2515 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_registry.py @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any + +import pytest +from nemo_deployments_plugin.backends.base import BackendStatusUpdate, DeploymentBackend, LogResult, VolumeStatusUpdate +from nemo_deployments_plugin.backends.registry import ( + ExecutorNotFoundError, + ExecutorRegistry, + ExecutorSpec, + UnknownBackendTypeError, +) +from nemo_platform import AsyncNeMoPlatform + + +class _StubBackend(DeploymentBackend): + def shutdown(self) -> None: + pass + + async def create_deployment(self, **kwargs: Any) -> BackendStatusUpdate: + return BackendStatusUpdate(status="PENDING") + + async def read_status(self, **kwargs: Any) -> BackendStatusUpdate: + return BackendStatusUpdate(status="READY") + + async def delete_deployment(self, workspace: str, name: str) -> BackendStatusUpdate: + return BackendStatusUpdate(status="DELETING") + + async def list_managed_deployment_names(self) -> list[str]: + return [] + + async def get_logs(self, **kwargs: Any) -> LogResult: + return LogResult(lines=[]) + + async def create_volume(self, **kwargs: Any) -> VolumeStatusUpdate: + return VolumeStatusUpdate(status="PENDING") + + async def read_volume_status(self, **kwargs: Any) -> VolumeStatusUpdate: + return VolumeStatusUpdate(status="BOUND") + + async def delete_volume(self, workspace: str, name: str) -> VolumeStatusUpdate: + return VolumeStatusUpdate(status="RELEASED") + + +@pytest.fixture +def backend_classes() -> dict[str, type[DeploymentBackend]]: + return {"docker": _StubBackend, "k8s": _StubBackend} + + +def test_empty_registry_starts(backend_classes: dict[str, type[DeploymentBackend]]) -> None: + registry = ExecutorRegistry.empty() + assert registry.registered_names() == [] + with pytest.raises(ExecutorNotFoundError): + registry.resolve() + + +def test_resolve_by_name(backend_classes: dict[str, type[DeploymentBackend]]) -> None: + sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") + registry = ExecutorRegistry.from_config( + sdk, + [ + ExecutorSpec(name="local-docker", backend="docker", config={"port_range_start": 9000}), + ExecutorSpec(name="cluster-a", backend="k8s", config={}), + ], + default_executor="local-docker", + backend_classes=backend_classes, + ) + assert registry.resolve("cluster-a") is not None + assert registry.resolve() is registry.resolve("local-docker") + + +def test_missing_executor_raises(backend_classes: dict[str, type[DeploymentBackend]]) -> None: + sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") + registry = ExecutorRegistry.from_config( + sdk, + [ExecutorSpec(name="a", backend="docker", config={})], + backend_classes=backend_classes, + ) + with pytest.raises(ExecutorNotFoundError): + registry.resolve("missing") + + +def test_unknown_backend_type_raises() -> None: + sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") + with pytest.raises(UnknownBackendTypeError): + ExecutorRegistry.from_config( + sdk, + [ExecutorSpec(name="a", backend="unknown", config={})], + backend_classes={"docker": _StubBackend}, + ) + + +class _FailingBackend(_StubBackend): + def init(self) -> None: + raise RuntimeError("init failed") + + +def test_registry_rolls_back_on_partial_init(backend_classes: dict[str, type[DeploymentBackend]]) -> None: + sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") + classes = {**backend_classes, "fail": _FailingBackend} + shutdown_calls: list[str] = [] + + class _TrackingStub(_StubBackend): + def shutdown(self) -> None: + shutdown_calls.append("shutdown") + + classes["docker"] = _TrackingStub + with pytest.raises(RuntimeError, match="init failed"): + ExecutorRegistry.from_config( + sdk, + [ + ExecutorSpec(name="ok", backend="docker", config={}), + ExecutorSpec(name="bad", backend="fail", config={}), + ], + backend_classes=classes, + ) + assert shutdown_calls == ["shutdown"] + + +def test_multiple_docker_executors_distinct_config(backend_classes: dict[str, type[DeploymentBackend]]) -> None: + sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") + registry = ExecutorRegistry.from_config( + sdk, + [ + ExecutorSpec(name="docker-a", backend="docker", config={"port_range_start": 9000}), + ExecutorSpec(name="docker-b", backend="docker", config={"port_range_start": 9100}), + ], + backend_classes=backend_classes, + ) + a = registry.resolve("docker-a") + b = registry.resolve("docker-b") + assert a._config["port_range_start"] == 9000 + assert b._config["port_range_start"] == 9100 diff --git a/plugins/nemo-deployments/tests/unit/test_service_startup.py b/plugins/nemo-deployments/tests/unit/test_service_startup.py new file mode 100644 index 0000000000..6826a4f9fa --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_service_startup.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from fastapi.routing import APIRoute +from nemo_deployments_plugin.service import DeploymentsService + + +def _mounted_paths() -> set[str]: + service = DeploymentsService() + paths: set[str] = set() + for spec in service.get_routers(): + for route in spec.router.routes: + if isinstance(route, APIRoute): + paths.add(f"/apis/deployments{spec.prefix}{route.path}") + return paths + + +def test_service_mounts_core_routes() -> None: + paths = _mounted_paths() + assert "/apis/deployments/v2/workspaces/{workspace}/deployment-configs" in paths + assert "/apis/deployments/v2/workspaces/{workspace}/deployments" in paths + assert "/apis/deployments/v2/workspaces/{workspace}/volumes" in paths + assert "/apis/deployments/v2/workspaces/{workspace}/deployments/{name}/status" in paths + assert "/apis/deployments/v2/workspaces/{workspace}/volumes/{name}/status" in paths + + +def test_service_name_matches_entry_point() -> None: + assert DeploymentsService.name == "deployments" + + +def test_service_authz_covers_mounted_routes() -> None: + contribution = DeploymentsService.get_authz_contribution() + endpoint_paths = set(contribution.endpoints.keys()) + for path in _mounted_paths(): + assert path in endpoint_paths, f"missing authz entry for {path}" + route_methods = { + method.lower() + for spec in DeploymentsService().get_routers() + for route in spec.router.routes + if isinstance(route, APIRoute) and f"/apis/deployments{spec.prefix}{route.path}" == path + for method in route.methods or set() + } + for method in route_methods: + assert method in contribution.endpoints[path], f"missing authz method {method} for {path}" diff --git a/pyproject.toml b/pyproject.toml index d4e2e5f372..254c31e168 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,6 +173,7 @@ enabled-plugins = [ "nemo-safe-synthesizer-plugin", "nemo-switchyard", "nemo-agents-plugin", + "nemo-deployments-plugin", "nemo-customizer-plugin", "nemo-automodel-plugin", "nemo-unsloth-plugin", @@ -364,6 +365,7 @@ nemo-auditor-plugin = { workspace = true } nemo-safe-synthesizer-plugin = { workspace = true } nemo-switchyard = { workspace = true } nemo-agents-plugin = { workspace = true } +nemo-deployments-plugin = { workspace = true } nemo-agents-example-calculator = { workspace = true } nemo-customizer-plugin = { workspace = true } nemo-automodel-plugin = { workspace = true } @@ -417,6 +419,7 @@ members = [ "plugins/nemo-safe-synthesizer", "plugins/nemo-switchyard", "plugins/nemo-agents", + "plugins/nemo-deployments", "plugins/nemo-agents/examples/calculator-agent", "plugins/nemo-customizer", "plugins/nemo-automodel", diff --git a/pytest.ini b/pytest.ini index cd079a4899..25a75bdb6f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -9,6 +9,8 @@ python_functions = test_* pythonpath = . plugins/example-plugin/src + plugins/nemo-deployments/src + plugins/nemo-deployments/tests/unit plugins/nemo-safe-synthesizer/src # Test discovery paths - packages and stable services diff --git a/script/generate_openapi_spec.py b/script/generate_openapi_spec.py index 275158f528..2700438b70 100644 --- a/script/generate_openapi_spec.py +++ b/script/generate_openapi_spec.py @@ -389,18 +389,24 @@ def extract_plugin_openapi_spec(plugin: PluginConfig) -> tuple[str, bool, str]: def extract_plugin_specs_with_process_pool(plugins: List[PluginConfig]) -> None: - """Extract OpenAPI specs for plugins via ProcessPool — mirrors the services path.""" + """Extract OpenAPI specs for plugins via isolated subprocesses. + + Each plugin gets a fresh ProcessPoolExecutor(max_workers=1) so a worker never + processes two plugins in one process. That matters on Linux (fork): discovering + services for plugin A imports route modules for plugin B, which registers + query-param filter schemas at import time; plugin B's extraction then calls + clear_query_param_schemas() and openapi() without re-importing those routes, + leaving dangling ``#/components/schemas/*Filter`` refs (e.g. MetricFilter). + """ print_green(f"=== Generating OpenAPI specs for {len(plugins)} plugin(s) using process pool ===") if not plugins: return - with ProcessPoolExecutor() as executor: - future_to_plugin = {executor.submit(extract_plugin_openapi_spec, p): p for p in plugins} - - failed_plugins = [] - for future in as_completed(future_to_plugin): - plugin = future_to_plugin[future] + failed_plugins = [] + for plugin in plugins: + with ProcessPoolExecutor(max_workers=1) as executor: + future = executor.submit(extract_plugin_openapi_spec, plugin) try: name, success, error = future.result() if success: @@ -412,13 +418,13 @@ def extract_plugin_specs_with_process_pool(plugins: List[PluginConfig]) -> None: failed_plugins.append((plugin.dir, str(e))) print_red(f"Exception in {plugin.dir}: {str(e)}") - if failed_plugins: - print_red(f"\n{len(failed_plugins)} plugins failed:") - for name, error in failed_plugins: - print_red(f" - {name}: {error}") - raise RuntimeError(f"{len(failed_plugins)} plugins failed to generate OpenAPI specs") + if failed_plugins: + print_red(f"\n{len(failed_plugins)} plugins failed:") + for name, error in failed_plugins: + print_red(f" - {name}: {error}") + raise RuntimeError(f"{len(failed_plugins)} plugins failed to generate OpenAPI specs") - print_green(f"All {len(plugins)} plugin(s) completed successfully!") + print_green(f"All {len(plugins)} plugin(s) completed successfully!") def apply_schema_fixes(spec_files: List[str], apply_reorder: bool = True) -> None: diff --git a/uv.lock b/uv.lock index bca521c1a3..a3aa1dce32 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,7 @@ members = [ "nemo-automodel-plugin", "nemo-customizer-plugin", "nemo-data-designer-plugin", + "nemo-deployments-plugin", "nemo-evaluator-plugin", "nemo-evaluator-sdk", "nemo-guardrails-plugin", @@ -4095,6 +4096,41 @@ requires-dist = [ ] provides-extras = ["test", "data-designer-nemo", "nemo-platform-plugin"] +[[package]] +name = "nemo-deployments-plugin" +version = "0.1.0" +source = { editable = "plugins/nemo-deployments" } +dependencies = [ + { name = "fastapi", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nemo-platform", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nemo-platform-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "pydantic", extra = ["email"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] + +[package.dev-dependencies] +dev = [ + { name = "fastapi", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "httpx", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "pytest", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "pytest-asyncio", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "nemo-platform", editable = "packages/nemo_platform" }, + { name = "nemo-platform-plugin", editable = "packages/nemo_platform_plugin" }, + { name = "pydantic", specifier = ">=2.10.6" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", specifier = ">=0.25.3" }, +] + [[package]] name = "nemo-evaluator-plugin" version = "0.1.0" @@ -5878,6 +5914,7 @@ core-services = [ { name = "nemo-automodel-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-customizer-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-data-designer-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nemo-deployments-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-evaluator-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-guardrails-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-platform", extra = ["services"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, @@ -5972,6 +6009,7 @@ enabled-plugins = [ { name = "nemo-automodel-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-customizer-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-data-designer-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nemo-deployments-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-evaluator-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-guardrails-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-safe-synthesizer-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, @@ -5985,6 +6023,7 @@ functional-services = [ { name = "nemo-automodel-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-customizer-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-data-designer-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nemo-deployments-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-evaluator-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-guardrails-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-platform", extra = ["services"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, @@ -6074,6 +6113,7 @@ core-services = [ { name = "nemo-automodel-plugin", editable = "plugins/nemo-automodel" }, { name = "nemo-customizer-plugin", editable = "plugins/nemo-customizer" }, { name = "nemo-data-designer-plugin", editable = "plugins/nemo-data-designer" }, + { name = "nemo-deployments-plugin", editable = "plugins/nemo-deployments" }, { name = "nemo-evaluator-plugin", editable = "plugins/nemo-evaluator" }, { name = "nemo-guardrails-plugin", editable = "plugins/nemo-guardrails" }, { name = "nemo-platform", editable = "packages/nemo_platform" }, @@ -6171,6 +6211,7 @@ enabled-plugins = [ { name = "nemo-automodel-plugin", editable = "plugins/nemo-automodel" }, { name = "nemo-customizer-plugin", editable = "plugins/nemo-customizer" }, { name = "nemo-data-designer-plugin", editable = "plugins/nemo-data-designer" }, + { name = "nemo-deployments-plugin", editable = "plugins/nemo-deployments" }, { name = "nemo-evaluator-plugin", editable = "plugins/nemo-evaluator" }, { name = "nemo-guardrails-plugin", editable = "plugins/nemo-guardrails" }, { name = "nemo-safe-synthesizer-plugin", editable = "plugins/nemo-safe-synthesizer" }, @@ -6184,6 +6225,7 @@ functional-services = [ { name = "nemo-automodel-plugin", editable = "plugins/nemo-automodel" }, { name = "nemo-customizer-plugin", editable = "plugins/nemo-customizer" }, { name = "nemo-data-designer-plugin", editable = "plugins/nemo-data-designer" }, + { name = "nemo-deployments-plugin", editable = "plugins/nemo-deployments" }, { name = "nemo-evaluator-plugin", editable = "plugins/nemo-evaluator" }, { name = "nemo-guardrails-plugin", editable = "plugins/nemo-guardrails" }, { name = "nemo-platform", editable = "packages/nemo_platform" },