# Project Pause Plugin A OpenShift Console plugin that allows users to pause and resume projects (namespaces) through the web interface. This plugin integrates with the project-pause-operator to manage project state. ## Features - Pause/Resume projects from the OpenShift Console - View project pause status in project overview - Track when projects were paused - Integration with project-pause-operator ## Development ### Prerequisites - [Node.js](https://nodejs.org/en/) - [yarn](https://yarnpkg.com) - [Docker](https://www.docker.com) or [podman 3.2.0+](https://podman.io) - [oc](https://console.redhat.com/openshift/downloads) ### Local Development In one terminal window: ```bash yarn install yarn run start ``` In another terminal window: ```bash oc login # login to your cluster yarn run start-console ``` The plugin HTTP server runs on port 9001. Navigate to http://localhost:9000/example to see the running plugin. ### Building the Image 1. Build: ```bash docker build -t quay.io/my-repository/project-pause-plugin:latest . ``` 2. Push: ```bash docker push quay.io/my-repository/project-pause-plugin:latest ``` Note: For Apple Silicon Macs, add `--platform=linux/amd64` when building. ### Deployment Deploy using Helm: ```bash helm upgrade -i project-pause-plugin charts/openshift-console-plugin \ -n plugin__project-pause-plugin --create-namespace \ --set plugin.image=quay.io/my-repository/project-pause-plugin:latest ``` ## Required Operator Integration The plugin requires a project-pause-operator that implements the following REST endpoints: ### 1. Get Project State ``` GET /api/proxy/project-pause-operator/v1/namespaces/{namespace}/state Response: { "isPaused": boolean, "pausedSince": string (ISO 8601 timestamp), "error": string (optional) } ``` ### 2. Toggle Project State ``` POST /api/proxy/project-pause-operator/v1/namespaces/{namespace}/toggle Request: { "pause": boolean } Response: { "success": boolean, "error": string (optional) } ``` ### CRD Annotations The operator should manage these annotations on namespaces: ```yaml apiVersion: v1 kind: Namespace metadata: annotations: project-pause-operator.openshift.io/paused: "true" project-pause-operator.openshift.io/paused-since: "2024-03-19T10:30:00Z" project-pause-operator.openshift.io/original-quota: '{"hard":{"cpu":"4","memory":"16Gi"}}' ``` ### Example Operator Implementation ```go const ( PausedAnnotation = "project-pause-operator.openshift.io/paused" PausedSinceAnnotation = "project-pause-operator.openshift.io/paused-since" ) type ProjectState struct { IsPaused bool `json:"isPaused"` PausedSince string `json:"pausedSince,omitempty"` Error string `json:"error,omitempty"` } // GetProjectState handles the GET state endpoint func (r *ProjectPauseReconciler) GetProjectState(w http.ResponseWriter, req *http.Request) { namespace := mux.Vars(req)["namespace"] ns, err := r.KubeClient.CoreV1().Namespaces().Get(context.Background(), namespace, metav1.GetOptions{}) if err != nil { respondWithError(w, http.StatusNotFound, "namespace not found") return } state := ProjectState{ IsPaused: ns.Annotations[PausedAnnotation] == "true", } if state.IsPaused { state.PausedSince = ns.Annotations[PausedSinceAnnotation] } json.NewEncoder(w).Encode(state) } // See full example in operator repository ``` ## Plugin Architecture The plugin provides: 1. Example test page at `/example` 2. Project overview integration using `console.project-overview/inventory-item` ### i18n Support The plugin uses the namespace `plugin__project-pause-plugin` for translations. Update messages by running: ```bash yarn i18n ``` ### Security Considerations The operator must validate that users have appropriate permissions before allowing pause/resume actions: 1. Use SubjectAccessReview to verify the user has `update` permission on the namespace 2. Ensure the request is authenticated using the user's token 3. Validate the namespace matches the one in the URL path Example security implementation: ```go // validateAccess checks if the user has permission to modify the namespace func (r *ProjectPauseReconciler) validateAccess(req *http.Request, namespace string) error { // Get user info from request context userInfo, err := getUserInfo(req) if err != nil { return fmt.Errorf("failed to get user info: %v", err) } // Create SubjectAccessReview sar := &authorizationv1.SubjectAccessReview{ Spec: authorizationv1.SubjectAccessReviewSpec{ User: userInfo.Username, Groups: userInfo.Groups, ResourceAttributes: &authorizationv1.ResourceAttributes{ Namespace: namespace, Verb: "update", Resource: "namespaces", Group: "", }, }, } // Check authorization result, err := r.KubeClient.AuthorizationV1().SubjectAccessReviews().Create( context.Background(), sar, metav1.CreateOptions{}) if err != nil { return fmt.Errorf("failed to check authorization: %v", err) } if !result.Status.Allowed { return fmt.Errorf("user not authorized to modify namespace %s", namespace) } return nil } // Example usage in handler func (r *ProjectPauseReconciler) ToggleProjectState(w http.ResponseWriter, req *http.Request) { namespace := mux.Vars(req)["namespace"] // Validate user has permission if err := r.validateAccess(req, namespace); err != nil { respondWithError(w, http.StatusForbidden, err.Error()) return } // Continue with toggle operation... } ``` ### Quota Management The operator manages resource quotas when pausing/resuming projects: ```go type QuotaBackup struct { Hard v1.ResourceList `json:"hard"` } // pauseNamespace handles the actual pause operation func (r *ProjectPauseReconciler) pauseNamespace(namespace string) error { // Get current quota quota, err := r.KubeClient.CoreV1().ResourceQuotas(namespace).Get( context.Background(), "compute-resources", // or your quota name metav1.GetOptions{}, ) if err != nil && !errors.IsNotFound(err) { return fmt.Errorf("failed to get quota: %v", err) } // Backup current quota to annotation if quota != nil { backup := QuotaBackup{ Hard: quota.Spec.Hard, } backupJSON, err := json.Marshal(backup) if err != nil { return fmt.Errorf("failed to marshal quota backup: %v", err) } ns, err := r.KubeClient.CoreV1().Namespaces().Get( context.Background(), namespace, metav1.GetOptions{}, ) if err != nil { return fmt.Errorf("failed to get namespace: %v", err) } if ns.Annotations == nil { ns.Annotations = make(map[string]string) } ns.Annotations["project-pause-operator.openshift.io/original-quota"] = string(backupJSON) _, err = r.KubeClient.CoreV1().Namespaces().Update( context.Background(), ns, metav1.UpdateOptions{}, ) if err != nil { return fmt.Errorf("failed to backup quota: %v", err) } // Set quota to zero quota.Spec.Hard = v1.ResourceList{ v1.ResourceCPU: resource.MustParse("0"), v1.ResourceMemory: resource.MustParse("0"), } _, err = r.KubeClient.CoreV1().ResourceQuotas(namespace).Update( context.Background(), quota, metav1.UpdateOptions{}, ) if err != nil { return fmt.Errorf("failed to update quota: %v", err) } } // Delete all pods err = r.KubeClient.CoreV1().Pods(namespace).DeleteCollection( context.Background(), metav1.DeleteOptions{}, metav1.ListOptions{}, ) if err != nil { return fmt.Errorf("failed to delete pods: %v", err) } return nil } // resumeNamespace restores the original quota func (r *ProjectPauseReconciler) resumeNamespace(namespace string) error { ns, err := r.KubeClient.CoreV1().Namespaces().Get( context.Background(), namespace, metav1.GetOptions{}, ) if err != nil { return fmt.Errorf("failed to get namespace: %v", err) } // Restore quota from backup quotaJSON := ns.Annotations["project-pause-operator.openshift.io/original-quota"] if quotaJSON != "" { var backup QuotaBackup if err := json.Unmarshal([]byte(quotaJSON), &backup); err != nil { return fmt.Errorf("failed to unmarshal quota backup: %v", err) } quota, err := r.KubeClient.CoreV1().ResourceQuotas(namespace).Get( context.Background(), "compute-resources", // or your quota name metav1.GetOptions{}, ) if err != nil { if !errors.IsNotFound(err) { return fmt.Errorf("failed to get quota: %v", err) } // Create new quota if it doesn't exist quota = &v1.ResourceQuota{ ObjectMeta: metav1.ObjectMeta{ Name: "compute-resources", Namespace: namespace, }, } } quota.Spec.Hard = backup.Hard if quota.ResourceVersion == "" { _, err = r.KubeClient.CoreV1().ResourceQuotas(namespace).Create( context.Background(), quota, metav1.CreateOptions{}, ) } else { _, err = r.KubeClient.CoreV1().ResourceQuotas(namespace).Update( context.Background(), quota, metav1.UpdateOptions{}, ) } if err != nil { return fmt.Errorf("failed to restore quota: %v", err) } } return nil }