diff --git a/Makefile-quickstart b/Makefile-quickstart index 8c45c6e2..df36c24a 100644 --- a/Makefile-quickstart +++ b/Makefile-quickstart @@ -86,7 +86,7 @@ SSH_PUBKEY = $(shell cat "$(SSH_KEY_PATH).pub" 2>/dev/null) keycloak keycloak-issuer login whoami logout delete-keycloak \ openshell-saw-create openshell-saw-list openshell-saw-logs openshell-saw-ssh ssh-secret \ openshell-saw-configure-gateway openshell-saw-tui openshell-saw-gui test \ - openshell-saw-delete delete-all status \ + openshell-saw-delete delete-all status deploy-rhdh delete-rhdh \ governance-list-profiles governance-add-profile governance-remove-profile governance-create-profile governance-demo \ .check-saw-name .check-ssh-key .check-prereqs @@ -376,6 +376,71 @@ delete-all: ## Uninstall everything (keycloak + imagestreams + gateway image) # --- Status --- +RHDH_CHART := charts/openshell-rhdh +RHDH_NS ?= rhdh +RHDH_APPS_DOMAIN ?= +RHDH_BASE_URL ?= + +RHDH_REPO_URL ?= + +deploy-rhdh: ## Deploy RHDH self-service portal + @if [ -z "$(RHDH_APPS_DOMAIN)" ]; then \ + RHDH_APPS_DOMAIN=$$(oc get ingresses.config.openshift.io cluster -o jsonpath='{.spec.domain}' 2>/dev/null); \ + else \ + RHDH_APPS_DOMAIN="$(RHDH_APPS_DOMAIN)"; \ + fi; \ + if [ -z "$(RHDH_BASE_URL)" ]; then \ + RHDH_BASE_URL="https://backstage-developer-hub-$(RHDH_NS).$${RHDH_APPS_DOMAIN}"; \ + else \ + RHDH_BASE_URL="$(RHDH_BASE_URL)"; \ + fi; \ + KC_HOST=$$(oc get route -n keycloak-system -l app=keycloak -o jsonpath='{.items[0].spec.host}' 2>/dev/null); \ + if [ -z "$${KC_HOST}" ]; then \ + KC_HOST=$$(oc get route -n $(NS) -l app=keycloak -o jsonpath='{.items[0].spec.host}' 2>/dev/null); \ + fi; \ + RHDH_CLIENT_SECRET=$$(oc get secret rhdh-oidc-credentials -n $(RHDH_NS) -o jsonpath='{.data.AUTH_OIDC_CLIENT_SECRET}' 2>/dev/null | base64 -d); \ + if [ -z "$${RHDH_CLIENT_SECRET}" ]; then \ + RHDH_CLIENT_SECRET=$$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 32); \ + echo "Generated new OIDC client secret."; \ + else \ + echo "Reusing existing OIDC client secret."; \ + fi; \ + REPO_OPTS=""; \ + if [ -n "$(RHDH_REPO_URL)" ]; then \ + REPO_OPTS="--set pipelines.repoUrl=$(RHDH_REPO_URL)"; \ + fi; \ + echo "Deploying RHDH chart to namespace $(RHDH_NS)..."; \ + echo " Apps domain: $${RHDH_APPS_DOMAIN}"; \ + echo " Keycloak: $${KC_HOST}"; \ + KC_SECRET_FILE=$$(mktemp); \ + printf '%s' "$${RHDH_CLIENT_SECRET}" > "$${KC_SECRET_FILE}"; \ + helm upgrade --install openshell-rhdh $(RHDH_CHART) \ + --namespace $(RHDH_NS) --create-namespace \ + --set appsDomain="$${RHDH_APPS_DOMAIN}" \ + --set baseUrl="$${RHDH_BASE_URL}" \ + --set keycloak.host="$${KC_HOST}" \ + --set-file keycloak.clientSecret="$${KC_SECRET_FILE}" \ + --set pipelines.namespace="$(NS)" \ + $${REPO_OPTS}; \ + rm -f "$${KC_SECRET_FILE}"; \ + echo "Creating K8s backend tokens..."; \ + K8S_TOKEN=$$(oc -n $(RHDH_NS) create token rhdh-k8s-backend --duration=8760h); \ + CATALOG_TOKEN=$$(oc -n $(RHDH_NS) create token rhdh-catalog-proxy --duration=8760h); \ + oc -n $(RHDH_NS) create secret generic rhdh-k8s-tokens \ + --from-literal=K8S_API_TOKEN="$${K8S_TOKEN}" \ + --from-literal=K8S_CATALOG_TOKEN="$${CATALOG_TOKEN}" \ + --from-literal=K8S_API_URL="https://kubernetes.default.svc" \ + --dry-run=client -o yaml | oc apply -f - >/dev/null; \ + oc rollout restart deployment backstage-developer-hub -n $(RHDH_NS) 2>/dev/null || true + @echo "" + @echo "RHDH deployed. Access at:" + @echo " $$(oc get route backstage-developer-hub -n $(RHDH_NS) -o jsonpath='https://{.spec.host}' 2>/dev/null || echo '(waiting for route...)')" + +delete-rhdh: ## Remove RHDH self-service portal + -helm uninstall openshell-rhdh --namespace $(RHDH_NS) 2>/dev/null + -oc -n $(RHDH_NS) delete secret rhdh-k8s-tokens --ignore-not-found 2>/dev/null + @echo "RHDH uninstalled." + status: ## Show status of all OpenShell resources @echo "=== Helm releases ===" && helm list -n $(NS) 2>/dev/null || true @echo "" && echo "=== VMs ===" && oc -n $(NS) get vm 2>/dev/null || true diff --git a/charts/openshell-keycloak/templates/realm-import.yaml b/charts/openshell-keycloak/templates/realm-import.yaml index fc7244de..1f390938 100644 --- a/charts/openshell-keycloak/templates/realm-import.yaml +++ b/charts/openshell-keycloak/templates/realm-import.yaml @@ -107,6 +107,45 @@ spec: included.client.audience: {{ .Values.keycloak.clients.cli.clientId | quote }} id.token.claim: "false" access.token.claim: "true" + {{- if .Values.keycloak.clients.rhdh }} + - clientId: {{ .Values.keycloak.clients.rhdh.clientId | quote }} + name: Red Hat Developer Hub + enabled: true + publicClient: false + clientAuthenticatorType: client-secret + secret: {{ .Values.keycloak.clients.rhdh.clientSecret | quote }} + standardFlowEnabled: true + directAccessGrantsEnabled: false + serviceAccountsEnabled: false + attributes: + pkce.code.challenge.method: S256 + redirectUris: + {{- range .Values.keycloak.clients.rhdh.redirectUris }} + - {{ . | quote }} + {{- end }} + webOrigins: + {{- range .Values.keycloak.clients.rhdh.webOrigins }} + - {{ . | quote }} + {{- end }} + protocolMappers: + - name: realm-roles + protocol: openid-connect + protocolMapper: oidc-usermodel-realm-role-mapper + config: + multivalued: "true" + claim.name: realm_access.roles + jsonType.label: String + id.token.claim: "true" + access.token.claim: "true" + userinfo.token.claim: "true" + - name: audience + protocol: openid-connect + protocolMapper: oidc-audience-mapper + config: + included.client.audience: {{ .Values.keycloak.clients.rhdh.clientId | quote }} + id.token.claim: "true" + access.token.claim: "true" + {{- end }} users: {{- range .Values.keycloak.testUsers }} - username: {{ .username | quote }} diff --git a/charts/openshell-keycloak/values.yaml b/charts/openshell-keycloak/values.yaml index b1f53618..e4513f20 100644 --- a/charts/openshell-keycloak/values.yaml +++ b/charts/openshell-keycloak/values.yaml @@ -12,7 +12,7 @@ keycloak: hostname: "" realm: openshell - registrationAllowed: true + registrationAllowed: false clients: cli: @@ -22,6 +22,11 @@ keycloak: # 86400 = 24 hours. Sandboxes run for days, so short-lived tokens cause # ssh-proxy and dashboard failures. accessTokenLifespan: 86400 + rhdh: + clientId: rhdh + clientSecret: "" + redirectUris: [] + webOrigins: [] dashboard: clientId: openshell-dashboard public: true diff --git a/charts/openshell-rhdh/.helmignore b/charts/openshell-rhdh/.helmignore new file mode 100644 index 00000000..1acd565f --- /dev/null +++ b/charts/openshell-rhdh/.helmignore @@ -0,0 +1,7 @@ +.git +.gitignore +.DS_Store +*.swp +*.bak +*.tmp +*.orig diff --git a/charts/openshell-rhdh/Chart.yaml b/charts/openshell-rhdh/Chart.yaml new file mode 100644 index 00000000..c98faf57 --- /dev/null +++ b/charts/openshell-rhdh/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: openshell-rhdh +description: Red Hat Developer Hub for OpenShell self-service sandbox provisioning +type: application +version: 0.1.0 +appVersion: "1.5" diff --git a/charts/openshell-rhdh/files/check-sandbox-status.yaml b/charts/openshell-rhdh/files/check-sandbox-status.yaml new file mode 100644 index 00000000..d37a7499 --- /dev/null +++ b/charts/openshell-rhdh/files/check-sandbox-status.yaml @@ -0,0 +1,80 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: check-saw-sandbox-status + title: Check Sandbox Status + description: Check the provisioning status of your agent sandbox + tags: + - openshell + - sandbox + - status +spec: + owner: infrastructure + type: service + parameters: + - title: Select Sandbox + required: + - name + properties: + name: + title: Sandbox Name + type: string + maxLength: 19 + pattern: "^[a-z][a-z0-9-]*$" + description: "Name of the sandbox to check" + steps: + - id: get-pipelinerun-status + name: Check Tekton PipelineRun + action: http:backstage:request + continueOnError: true + input: + method: GET + path: /proxy/k8s-pipelines?labelSelector=openshell.io/sandbox=${{ parameters.name }},tekton.dev/pipeline=saw-create-sandbox&limit=1 + headers: + Content-Type: application/json + + - id: get-vm-status + name: Check VM status + action: http:backstage:request + continueOnError: true + input: + method: GET + path: /proxy/k8s-status-vm/saw-${{ parameters.name }} + headers: + Content-Type: application/json + + - id: get-setup-job + name: Check setup job + action: http:backstage:request + continueOnError: true + input: + method: GET + path: /proxy/k8s-status-jobs/saw-${{ parameters.name }}-setup + headers: + Content-Type: application/json + + - id: get-routes + name: Check routes + action: http:backstage:request + continueOnError: true + input: + method: GET + path: /proxy/k8s-status-routes?labelSelector=app.kubernetes.io/instance=saw-${{ parameters.name }} + headers: + Content-Type: application/json + + output: + text: + - title: Sandbox Status + content: | + **Sandbox: ${{ parameters.name }}** + + **PipelineRun (helm install):** ${{ steps['get-pipelinerun-status'].output.body.items[0].status.conditions[0].reason | default('Not found') }} — ${{ steps['get-pipelinerun-status'].output.body.items[0].status.conditions[0].message | default('No PipelineRun found for this sandbox') }} + + **VM:** ${{ steps['get-vm-status'].output.body.status.printableStatus | default('Not found') }} + + **Setup Job:** ${{ steps['get-setup-job'].output.body.status.conditions[0].type | default('Not started') }} — succeeded: ${{ steps['get-setup-job'].output.body.status.succeeded | default('0') }}, failed: ${{ steps['get-setup-job'].output.body.status.failed | default('0') }} + + **Gateway:** https://saw-${{ parameters.name }}-gateway-__PIPELINES_NS__.__APPS_DOMAIN__ + + **Dashboard:** https://saw-${{ parameters.name }}-dashboard-__PIPELINES_NS__.__APPS_DOMAIN__ diff --git a/charts/openshell-rhdh/files/create-sandbox.yaml b/charts/openshell-rhdh/files/create-sandbox.yaml new file mode 100644 index 00000000..00b665f2 --- /dev/null +++ b/charts/openshell-rhdh/files/create-sandbox.yaml @@ -0,0 +1,163 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: create-saw-sandbox + title: Create Agent Sandbox + description: Provision a new OpenShell/OpenClaw agent sandbox + tags: + - openshell + - sandbox + - nemoclaw +spec: + owner: infrastructure + type: service + parameters: + - title: Sandbox Configuration + required: + - name + - provider + - model + - apiKey + properties: + name: + title: Sandbox Name + type: string + maxLength: 19 + pattern: "^[a-z][a-z0-9-]*$" + description: "DNS-safe name (max 19 chars, lowercase, starts with letter)" + provider: + title: Inference Provider + type: string + enum: + - gemini + - anthropic + - openai + - nvidia + - openrouter + - ollama + - custom + default: gemini + model: + title: Model Name + type: string + default: gemini-2.0-flash + pattern: "^[a-zA-Z0-9._:/-]+$" + maxLength: 64 + apiKey: + title: API Key + type: string + ui:field: Secret + description: "Inference provider API key (stored as Secret, never logged)" + steps: + - id: check-exists + name: Verify sandbox does not already exist + action: http:backstage:request + input: + method: GET + path: "/catalog/entities?filter=kind=resource,metadata.name=saw-${{ parameters.name }}" + headers: + Content-Type: application/json + + - id: block-if-exists + name: Block duplicate sandbox + action: debug:log + if: ${{ steps['check-exists'].output.body.length > 0 }} + input: + message: "Sandbox '${{ parameters.name }}' already exists. Choose a different name." + + - id: create-secret + name: Store inference API key + if: ${{ steps['check-exists'].output.body.length === 0 }} + action: http:backstage:request + input: + method: POST + path: /proxy/k8s-secrets + headers: + Content-Type: application/json + body: + apiVersion: v1 + kind: Secret + metadata: + name: "saw-${{ parameters.name }}-inference-key" + namespace: __PIPELINES_NS__ + labels: + app.kubernetes.io/managed-by: rhdh-scaffolder + openshell.io/owner: "${{ user.entity.metadata.name }}" + openshell.io/sandbox: "${{ parameters.name }}" + type: Opaque + stringData: + api-key: "${{ parameters.apiKey }}" + api_key: "${{ parameters.apiKey }}" + provider: "${{ parameters.provider }}" + model: "${{ parameters.model }}" + + - id: start-pipeline + name: Start sandbox provisioning + if: ${{ steps['check-exists'].output.body.length === 0 }} + action: http:backstage:request + input: + method: POST + path: /proxy/k8s-pipelines + headers: + Content-Type: application/json + body: + apiVersion: tekton.dev/v1 + kind: PipelineRun + metadata: + generateName: "saw-create-${{ parameters.name }}-" + namespace: __PIPELINES_NS__ + labels: + app.kubernetes.io/managed-by: rhdh-scaffolder + openshell.io/owner: "${{ user.entity.metadata.name }}" + openshell.io/sandbox: "${{ parameters.name }}" + tekton.dev/pipeline: saw-create-sandbox + spec: + pipelineRef: + name: saw-create-sandbox + taskRunTemplate: + serviceAccountName: saw-provisioner + params: + - name: sandbox-name + value: "${{ parameters.name }}" + - name: owner + value: "${{ user.entity.metadata.name }}" + - name: provider + value: "${{ parameters.provider }}" + - name: model + value: "${{ parameters.model }}" + - name: oidc-issuer-url + value: "https://__KEYCLOAK_HOST__/realms/openshell" + + - id: get-current-catalog + name: Read current catalog + if: ${{ steps['check-exists'].output.body.length === 0 }} + action: http:backstage:request + input: + method: GET + path: "/proxy/k8s-catalog" + headers: + Content-Type: application/json + + - id: register-catalog-entity + name: Register sandbox in catalog + if: ${{ steps['check-exists'].output.body.length === 0 }} + action: http:backstage:request + input: + method: PATCH + path: "/proxy/k8s-catalog" + headers: + Content-Type: application/json-patch+json + body: + - op: replace + path: /data/catalog.yaml + value: "${{ steps['get-current-catalog'].output.body.data['catalog.yaml'] }}---\napiVersion: backstage.io/v1alpha1\nkind: Resource\nmetadata:\n name: saw-${{ parameters.name }}\n namespace: default\n title: \"Sandbox: ${{ parameters.name }}\"\n description: Agent sandbox provisioned via self-service\n annotations:\n openshell.io/sandbox-name: \"${{ parameters.name }}\"\n openshell.io/provider: \"${{ parameters.provider }}\"\n openshell.io/model: \"${{ parameters.model }}\"\n openshell.io/dashboard-url: \"https://saw-${{ parameters.name }}-dashboard-__PIPELINES_NS__.__APPS_DOMAIN__\"\n janus-idp.io/tekton: __PIPELINES_NS__\n links:\n - url: \"https://saw-${{ parameters.name }}-gateway-__PIPELINES_NS__.__APPS_DOMAIN__\"\n title: Gateway\n icon: cloud\n - url: \"https://saw-${{ parameters.name }}-dashboard-__PIPELINES_NS__.__APPS_DOMAIN__\"\n title: Dashboard\n icon: dashboard\n - url: \"https://backstage-developer-hub-__RHDH_NS__.__APPS_DOMAIN__/create/templates/default/delete-saw-sandbox?formData=%7B%22name%22%3A%22${{ parameters.name }}%22%7D\"\n title: Delete Sandbox\n icon: delete\n labels:\n openshell.io/owner: \"${{ user.entity.metadata.name }}\"\n openshell.io/type: sandbox\nspec:\n type: sandbox\n owner: \"user:default/${{ user.entity.metadata.name }}\"\n lifecycle: production\n" + + output: + text: + - title: Status + content: | + Sandbox **${{ parameters.name }}** provisioning has been started. The VM typically takes 5-8 minutes to become ready. + + **Gateway:** https://saw-${{ parameters.name }}-gateway-__PIPELINES_NS__.__APPS_DOMAIN__ + + Your sandbox will appear in the **Catalog** under Resources within ~30 seconds. diff --git a/charts/openshell-rhdh/files/delete-sandbox.yaml b/charts/openshell-rhdh/files/delete-sandbox.yaml new file mode 100644 index 00000000..01a7b1f8 --- /dev/null +++ b/charts/openshell-rhdh/files/delete-sandbox.yaml @@ -0,0 +1,78 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: delete-saw-sandbox + title: Delete Agent Sandbox + description: Remove an existing OpenShell/OpenClaw agent sandbox you own + tags: + - openshell + - sandbox + - delete +spec: + owner: infrastructure + type: service + parameters: + - title: Select Sandbox + required: + - name + properties: + name: + title: Sandbox Name + type: string + maxLength: 19 + pattern: "^[a-z][a-z0-9-]*$" + description: "Name of the sandbox to delete (must be owned by you)" + steps: + - id: verify-ownership + name: Verify you own this sandbox + action: http:backstage:request + input: + method: GET + path: "/catalog/entities/by-name/resource/default/saw-${{ parameters.name }}" + headers: + Content-Type: application/json + + - id: check-ownership + name: Confirm ownership + action: debug:log + if: ${{ steps['verify-ownership'].output.body.spec.owner !== 'user:default/' + user.entity.metadata.name }} + input: + message: "Access denied: you do not own sandbox ${{ parameters.name }}" + + - id: start-delete-pipeline + name: Start sandbox deletion + if: ${{ steps['verify-ownership'].output.body.spec.owner === 'user:default/' + user.entity.metadata.name }} + action: http:backstage:request + input: + method: POST + path: /proxy/k8s-pipelines + headers: + Content-Type: application/json + body: + apiVersion: tekton.dev/v1 + kind: PipelineRun + metadata: + generateName: "saw-delete-${{ parameters.name }}-" + namespace: __PIPELINES_NS__ + labels: + app.kubernetes.io/managed-by: rhdh-scaffolder + openshell.io/owner: "${{ user.entity.metadata.name }}" + openshell.io/sandbox: "${{ parameters.name }}" + tekton.dev/pipeline: saw-delete-sandbox + spec: + pipelineRef: + name: saw-delete-sandbox + taskRunTemplate: + serviceAccountName: saw-provisioner + params: + - name: sandbox-name + value: "${{ parameters.name }}" + - name: owner + value: "${{ user.entity.metadata.name }}" + + output: + text: + - title: Status + content: | + Sandbox **${{ parameters.name }}** deletion has been started. Cleanup typically takes 1-2 minutes. + The sandbox will disappear from the catalog once the pipeline completes. diff --git a/charts/openshell-rhdh/templates/NOTES.txt b/charts/openshell-rhdh/templates/NOTES.txt new file mode 100644 index 00000000..c5b06f71 --- /dev/null +++ b/charts/openshell-rhdh/templates/NOTES.txt @@ -0,0 +1,11 @@ +OpenShell RHDH Self-Service Portal deployed. + +Access RHDH at: + https://backstage-developer-hub-{{ .Values.namespace }}.{{ .Values.appsDomain }} + +Login via Keycloak OIDC (realm: {{ .Values.keycloak.realm }}). + +Before first use, run: + make deploy-rhdh + +This seeds the K8s backend tokens required for sandbox provisioning. diff --git a/charts/openshell-rhdh/templates/_helpers.tpl b/charts/openshell-rhdh/templates/_helpers.tpl new file mode 100644 index 00000000..7dccf667 --- /dev/null +++ b/charts/openshell-rhdh/templates/_helpers.tpl @@ -0,0 +1,39 @@ +{{/* +Compute Keycloak hostname from global.clusterDomain. +Priority: explicit keycloak.host > computed from global.clusterDomain. +*/}} +{{- define "openshell-rhdh.keycloakHost" -}} + {{- if .Values.keycloak.host -}} + {{- .Values.keycloak.host -}} + {{- else if .Values.global -}} + {{- if .Values.global.clusterDomain -}} + {{- printf "%s-ingress-%s.apps.%s" .Values.keycloak.keycloakName (.Values.keycloak.keycloakNamespace | default .Values.pipelines.namespace) .Values.global.clusterDomain -}} + {{- end -}} + {{- end -}} +{{- end -}} + +{{/* +Compute apps domain. +Priority: explicit appsDomain > computed from global.clusterDomain. +*/}} +{{- define "openshell-rhdh.appsDomain" -}} + {{- if .Values.appsDomain -}} + {{- .Values.appsDomain -}} + {{- else if .Values.global -}} + {{- if .Values.global.clusterDomain -}} + {{- printf "apps.%s" .Values.global.clusterDomain -}} + {{- end -}} + {{- end -}} +{{- end -}} + +{{/* +Compute RHDH base URL. +Priority: explicit baseUrl > computed from appsDomain. +*/}} +{{- define "openshell-rhdh.baseUrl" -}} + {{- if .Values.baseUrl -}} + {{- .Values.baseUrl -}} + {{- else -}} + {{- printf "https://backstage-developer-hub-%s.%s" .Values.namespace (include "openshell-rhdh.appsDomain" .) -}} + {{- end -}} +{{- end -}} diff --git a/charts/openshell-rhdh/templates/admission-policy.yaml b/charts/openshell-rhdh/templates/admission-policy.yaml new file mode 100644 index 00000000..483c8206 --- /dev/null +++ b/charts/openshell-rhdh/templates/admission-policy.yaml @@ -0,0 +1,114 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: saw-pipelinerun-restrict + labels: + app.kubernetes.io/part-of: openshell-rhdh +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: ["tekton.dev"] + apiVersions: ["v1"] + operations: ["CREATE"] + resources: ["pipelineruns"] + scope: Namespaced + matchConditions: + - name: only-pipelines-namespace + expression: "object.metadata.namespace == '{{ .Values.pipelines.namespace }}'" + validations: + - expression: "has(object.spec.pipelineRef) && !has(object.spec.pipelineSpec)" + message: "PipelineRuns must use pipelineRef, inline pipelineSpec is not allowed." + reason: Forbidden + - expression: "object.spec.pipelineRef.name in ['saw-create-sandbox', 'saw-delete-sandbox']" + message: "PipelineRuns may only reference saw-create-sandbox or saw-delete-sandbox pipelines." + reason: Forbidden +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: saw-pipelinerun-restrict + labels: + app.kubernetes.io/part-of: openshell-rhdh +spec: + policyName: saw-pipelinerun-restrict + validationActions: + - Deny + matchResources: + namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.pipelines.namespace }} +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: saw-secret-naming + labels: + app.kubernetes.io/part-of: openshell-rhdh +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE"] + resources: ["secrets"] + scope: Namespaced + matchConditions: + - name: only-pipelines-namespace + expression: "object.metadata.namespace == '{{ .Values.pipelines.namespace }}'" + - name: only-rhdh-backend-sa + expression: "request.userInfo.username == 'system:serviceaccount:{{ .Values.namespace }}:rhdh-k8s-backend'" + validations: + - expression: "object.metadata.name.startsWith('saw-') && object.metadata.name.endsWith('-inference-key')" + message: "Secrets created by the RHDH backend must follow the naming convention 'saw--inference-key'." + reason: Forbidden +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: saw-secret-naming + labels: + app.kubernetes.io/part-of: openshell-rhdh +spec: + policyName: saw-secret-naming + validationActions: + - Deny + matchResources: + namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.pipelines.namespace }} +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: saw-clusterrole-naming + labels: + app.kubernetes.io/part-of: openshell-rhdh +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: ["rbac.authorization.k8s.io"] + apiVersions: ["v1"] + operations: ["CREATE"] + resources: ["clusterroles", "clusterrolebindings"] + scope: Cluster + matchConditions: + - name: only-saw-provisioner + expression: "request.userInfo.username == 'system:serviceaccount:{{ .Values.pipelines.namespace }}:{{ .Values.provisioner.serviceAccount }}'" + validations: + - expression: "object.metadata.name.startsWith('saw-')" + message: "ClusterRoles/ClusterRoleBindings created by saw-provisioner must have names starting with 'saw-'." + reason: Forbidden +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: saw-clusterrole-naming + labels: + app.kubernetes.io/part-of: openshell-rhdh +spec: + policyName: saw-clusterrole-naming + validationActions: + - Deny diff --git a/charts/openshell-rhdh/templates/backstage-cr.yaml b/charts/openshell-rhdh/templates/backstage-cr.yaml new file mode 100644 index 00000000..8d08793b --- /dev/null +++ b/charts/openshell-rhdh/templates/backstage-cr.yaml @@ -0,0 +1,32 @@ +apiVersion: rhdh.redhat.com/v1alpha5 +kind: Backstage +metadata: + name: developer-hub + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +spec: + application: + appConfig: + configMaps: + - name: app-config-rhdh + dynamicPluginsConfigMapName: dynamic-plugins-rhdh + extraEnvs: + envs: + - name: NODE_OPTIONS + value: "--no-node-snapshot" + secrets: + - name: rhdh-oidc-credentials + - name: rhdh-k8s-tokens + extraFiles: + configMaps: + - mountPath: /opt/app-root/src/templates + name: rhdh-templates + - mountPath: /opt/app-root/src/catalog-users + name: rhdh-lab-users + - mountPath: /opt/app-root/src/rbac + name: rhdh-rbac-policy + - mountPath: /opt/app-root/src/sandbox-entities + name: rhdh-sandbox-entities + database: + enableLocalDb: true diff --git a/charts/openshell-rhdh/templates/configmap-appconfig.yaml b/charts/openshell-rhdh/templates/configmap-appconfig.yaml new file mode 100644 index 00000000..f655c2e4 --- /dev/null +++ b/charts/openshell-rhdh/templates/configmap-appconfig.yaml @@ -0,0 +1,141 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config-rhdh + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +data: + app-config-rhdh.yaml: | + app: + title: Secure Agent Workspace + baseUrl: ${RHDH_BASE_URL} + backend: + baseUrl: ${RHDH_BASE_URL} + cors: + origin: ${RHDH_BASE_URL} + signInPage: oidc + auth: + environment: production + session: + secret: ${SESSION_SECRET} + sessionDuration: { hours: 24 } + providers: + oidc: + production: + metadataUrl: ${AUTH_OIDC_METADATA_URL} + clientId: ${AUTH_OIDC_CLIENT_ID} + clientSecret: ${AUTH_OIDC_CLIENT_SECRET} + prompt: login + signIn: + resolvers: + - resolver: preferredUsernameMatchingUserEntityName + # Required for self-service portal: allows any Keycloak user to sign in + # without pre-provisioning in the catalog. Expands login surface area. + dangerouslyAllowSignInWithoutUserInCatalog: true + catalog: + locations: + - type: file + target: /opt/app-root/src/templates/create-sandbox.yaml + rules: + - allow: [Template] + - type: file + target: /opt/app-root/src/templates/delete-sandbox.yaml + rules: + - allow: [Template] + - type: file + target: /opt/app-root/src/templates/check-sandbox-status.yaml + rules: + - allow: [Template] + - type: file + target: /opt/app-root/src/catalog-users/users.yaml + rules: + - allow: [User] + - type: file + target: /opt/app-root/src/sandbox-entities/catalog.yaml + rules: + - allow: [Resource] + rules: + - allow: [Component, System, API, Resource, Location, Template, User, Group] + processingInterval: { seconds: 30 } + permission: + enabled: true + rbac: + admin: + superUsers: + - name: user:default/admin + policies-csv-file: /opt/app-root/src/rbac/rbac-policy.csv + conditionalPoliciesFile: /opt/app-root/src/rbac/rbac-conditional-policies.yaml + policyFileReload: true + kubernetes: + serviceLocatorMethod: + type: 'multiTenant' + clusterLocatorMethods: + - type: 'config' + clusters: + - name: local + url: ${K8S_API_URL} + authProvider: 'serviceAccount' + serviceAccountToken: ${K8S_API_TOKEN} + proxy: + endpoints: + /k8s-catalog: + target: ${K8S_API_URL}/api/v1/namespaces/{{ .Values.namespace }}/configmaps/rhdh-sandbox-entities + changeOrigin: true + secure: false + credentials: require + allowedMethods: ['GET', 'PATCH'] + headers: + Authorization: "Bearer ${K8S_CATALOG_TOKEN}" + allowedHeaders: + - Content-Type + /k8s-secrets: + target: ${K8S_API_URL}/api/v1/namespaces/{{ .Values.pipelines.namespace }}/secrets + changeOrigin: true + secure: false + credentials: require + allowedMethods: ['POST'] + headers: + Authorization: "Bearer ${K8S_API_TOKEN}" + allowedHeaders: + - Content-Type + /k8s-pipelines: + target: ${K8S_API_URL}/apis/tekton.dev/v1/namespaces/{{ .Values.pipelines.namespace }}/pipelineruns + changeOrigin: true + secure: false + credentials: require + allowedMethods: ['GET', 'POST'] + headers: + Authorization: "Bearer ${K8S_API_TOKEN}" + allowedHeaders: + - Content-Type + /k8s-status-vm: + target: ${K8S_API_URL}/apis/kubevirt.io/v1/namespaces/{{ .Values.pipelines.namespace }}/virtualmachines + changeOrigin: true + secure: false + credentials: require + allowedMethods: ['GET'] + headers: + Authorization: "Bearer ${K8S_API_TOKEN}" + allowedHeaders: + - Content-Type + /k8s-status-jobs: + target: ${K8S_API_URL}/apis/batch/v1/namespaces/{{ .Values.pipelines.namespace }}/jobs + changeOrigin: true + secure: false + credentials: require + allowedMethods: ['GET'] + headers: + Authorization: "Bearer ${K8S_API_TOKEN}" + allowedHeaders: + - Content-Type + /k8s-status-routes: + target: ${K8S_API_URL}/apis/route.openshift.io/v1/namespaces/{{ .Values.pipelines.namespace }}/routes + changeOrigin: true + secure: false + credentials: require + allowedMethods: ['GET'] + headers: + Authorization: "Bearer ${K8S_API_TOKEN}" + allowedHeaders: + - Content-Type diff --git a/charts/openshell-rhdh/templates/configmap-dynamic-plugins.yaml b/charts/openshell-rhdh/templates/configmap-dynamic-plugins.yaml new file mode 100644 index 00000000..d204251e --- /dev/null +++ b/charts/openshell-rhdh/templates/configmap-dynamic-plugins.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: dynamic-plugins-rhdh + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +data: + dynamic-plugins.yaml: | + includes: + - dynamic-plugins.default.yaml + plugins: + - package: ./dynamic-plugins/dist/roadiehq-scaffolder-backend-module-http-request-dynamic + disabled: false + - package: ./dynamic-plugins/dist/backstage-community-plugin-rbac + disabled: false + - package: ./dynamic-plugins/dist/backstage-community-plugin-scaffolder-backend-module-kubernetes-dynamic + disabled: false + - package: ./dynamic-plugins/dist/backstage-plugin-kubernetes-backend-dynamic + disabled: false diff --git a/charts/openshell-rhdh/templates/configmap-lab-users.yaml b/charts/openshell-rhdh/templates/configmap-lab-users.yaml new file mode 100644 index 00000000..bac2f92e --- /dev/null +++ b/charts/openshell-rhdh/templates/configmap-lab-users.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: rhdh-lab-users + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +data: + users.yaml: | + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: developer + spec: + memberOf: [] + --- + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: admin + spec: + memberOf: [] + --- + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: alice + spec: + memberOf: [] + --- + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: bob + spec: + memberOf: [] diff --git a/charts/openshell-rhdh/templates/configmap-rbac-policy.yaml b/charts/openshell-rhdh/templates/configmap-rbac-policy.yaml new file mode 100644 index 00000000..935c0929 --- /dev/null +++ b/charts/openshell-rhdh/templates/configmap-rbac-policy.yaml @@ -0,0 +1,82 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: rhdh-rbac-policy + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +data: + rbac-policy.csv: | + # Roles + # role:default/sandbox-admin — full access to all sandboxes and catalog + # role:default/sandbox-user — access only to own resources (conditional) + + # Admin role — full catalog and scaffolder access + p, role:default/sandbox-admin, catalog-entity, read, allow + p, role:default/sandbox-admin, catalog-entity, update, allow + p, role:default/sandbox-admin, catalog-entity, delete, allow + p, role:default/sandbox-admin, catalog.entity.create, create, allow + p, role:default/sandbox-admin, catalog.location.create, create, allow + p, role:default/sandbox-admin, catalog.location.read, read, allow + p, role:default/sandbox-admin, catalog.location.delete, delete, allow + p, role:default/sandbox-admin, scaffolder-template, read, allow + p, role:default/sandbox-admin, scaffolder-action, use, allow + p, role:default/sandbox-admin, scaffolder.task.create, create, allow + p, role:default/sandbox-admin, scaffolder.task.read, read, allow + p, role:default/sandbox-admin, scaffolder.task.cancel, create, allow + + # User role — scaffolder access + catalog read/delete are conditional (see conditional policies file) + p, role:default/sandbox-user, catalog.entity.create, create, allow + p, role:default/sandbox-user, catalog.location.create, create, allow + p, role:default/sandbox-user, catalog.location.read, read, allow + p, role:default/sandbox-user, catalog.location.delete, delete, allow + p, role:default/sandbox-user, scaffolder-template, read, allow + p, role:default/sandbox-user, scaffolder-action, use, allow + p, role:default/sandbox-user, scaffolder.task.create, create, allow + p, role:default/sandbox-user, scaffolder.task.read, read, allow + + # Assign admin role + g, user:default/admin, role:default/sandbox-admin + + # Assign user role to lab users + g, user:default/alice, role:default/sandbox-user + g, user:default/bob, role:default/sandbox-user + g, user:default/developer, role:default/sandbox-user + + rbac-conditional-policies.yaml: | + --- + result: CONDITIONAL + roleEntityRef: role:default/sandbox-user + pluginId: catalog + resourceType: catalog-entity + permissionMapping: + - read + conditions: + anyOf: + - rule: IS_ENTITY_OWNER + resourceType: catalog-entity + params: + claims: + - $currentUser + - rule: IS_ENTITY_KIND + resourceType: catalog-entity + params: + kinds: + - template + - user + - group + - system + - location + --- + result: CONDITIONAL + roleEntityRef: role:default/sandbox-user + pluginId: catalog + resourceType: catalog-entity + permissionMapping: + - delete + conditions: + rule: IS_ENTITY_OWNER + resourceType: catalog-entity + params: + claims: + - $currentUser diff --git a/charts/openshell-rhdh/templates/configmap-sandbox-entities.yaml b/charts/openshell-rhdh/templates/configmap-sandbox-entities.yaml new file mode 100644 index 00000000..6ec568a0 --- /dev/null +++ b/charts/openshell-rhdh/templates/configmap-sandbox-entities.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: rhdh-sandbox-entities + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh + annotations: + argocd.argoproj.io/compare-options: IgnoreExtraneous + argocd.argoproj.io/sync-options: Prune=false + helm.sh/resource-policy: keep +data: + catalog.yaml: "" diff --git a/charts/openshell-rhdh/templates/configmap-templates.yaml b/charts/openshell-rhdh/templates/configmap-templates.yaml new file mode 100644 index 00000000..5c7989c6 --- /dev/null +++ b/charts/openshell-rhdh/templates/configmap-templates.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: rhdh-templates + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +data: + {{- $appsDomain := include "openshell-rhdh.appsDomain" . }} + {{- $keycloakHost := include "openshell-rhdh.keycloakHost" . }} + create-sandbox.yaml: | +{{ .Files.Get "files/create-sandbox.yaml" | replace "__PIPELINES_NS__" .Values.pipelines.namespace | replace "__APPS_DOMAIN__" $appsDomain | replace "__RHDH_NS__" .Values.namespace | replace "__KEYCLOAK_HOST__" $keycloakHost | indent 4 }} + delete-sandbox.yaml: | +{{ .Files.Get "files/delete-sandbox.yaml" | replace "__PIPELINES_NS__" .Values.pipelines.namespace | replace "__APPS_DOMAIN__" $appsDomain | replace "__RHDH_NS__" .Values.namespace | indent 4 }} + check-sandbox-status.yaml: | +{{ .Files.Get "files/check-sandbox-status.yaml" | replace "__PIPELINES_NS__" .Values.pipelines.namespace | replace "__APPS_DOMAIN__" $appsDomain | indent 4 }} diff --git a/charts/openshell-rhdh/templates/namespace.yaml b/charts/openshell-rhdh/templates/namespace.yaml new file mode 100644 index 00000000..f3adaa09 --- /dev/null +++ b/charts/openshell-rhdh/templates/namespace.yaml @@ -0,0 +1,10 @@ +{{- if .Capabilities.APIVersions.Has "v1" }} +{{- if not (lookup "v1" "Namespace" "" .Values.namespace) }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +{{- end }} +{{- end }} diff --git a/charts/openshell-rhdh/templates/pipeline-governance.yaml b/charts/openshell-rhdh/templates/pipeline-governance.yaml new file mode 100644 index 00000000..d4028783 --- /dev/null +++ b/charts/openshell-rhdh/templates/pipeline-governance.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: LimitRange +metadata: + name: pipeline-limits + namespace: {{ .Values.pipelines.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +spec: + limits: + - type: Container + default: + cpu: 500m + memory: 256Mi + defaultRequest: + cpu: 100m + memory: 128Mi +--- +apiVersion: v1 +kind: ResourceQuota +metadata: + name: pipeline-quota + namespace: {{ .Values.pipelines.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +spec: + hard: + pods: "10" + requests.cpu: "4" + requests.memory: 2Gi diff --git a/charts/openshell-rhdh/templates/rbac-provisioner.yaml b/charts/openshell-rhdh/templates/rbac-provisioner.yaml new file mode 100644 index 00000000..42ed5cdc --- /dev/null +++ b/charts/openshell-rhdh/templates/rbac-provisioner.yaml @@ -0,0 +1,71 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.provisioner.serviceAccount }} + namespace: {{ .Values.pipelines.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: saw-provisioner + labels: + app.kubernetes.io/part-of: openshell-rhdh +rules: + - apiGroups: [""] + resources: ["secrets", "configmaps", "services", "persistentvolumeclaims", "serviceaccounts", "pods", "pods/log"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["route.openshift.io"] + resources: ["routes"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["kubevirt.io"] + resources: ["virtualmachines", "virtualmachineinstances", "virtualmachines/start", "virtualmachines/stop"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["subresources.kubevirt.io"] + resources: ["virtualmachines/portforward", "virtualmachines/start", "virtualmachines/stop"] + verbs: ["get", "update"] + - apiGroups: ["cdi.kubevirt.io"] + resources: ["datavolumes", "datasources"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["roles", "rolebindings"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles", "clusterrolebindings"] + verbs: ["get", "list", "watch", "create", "delete"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles"] + verbs: ["bind"] + resourceNames: ["system:openshift:scc:anyuid"] + - apiGroups: ["security.openshift.io"] + resources: ["securitycontextconstraints"] + resourceNames: ["anyuid"] + verbs: ["use"] + - apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["tekton.dev"] + resources: ["pipelines", "pipelineruns", "tasks", "taskruns"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: saw-provisioner + labels: + app.kubernetes.io/part-of: openshell-rhdh +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: saw-provisioner +subjects: + - kind: ServiceAccount + name: {{ .Values.provisioner.serviceAccount }} + namespace: {{ .Values.pipelines.namespace }} diff --git a/charts/openshell-rhdh/templates/sa-k8s-backend.yaml b/charts/openshell-rhdh/templates/sa-k8s-backend.yaml new file mode 100644 index 00000000..7d14aa10 --- /dev/null +++ b/charts/openshell-rhdh/templates/sa-k8s-backend.yaml @@ -0,0 +1,84 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: rhdh-k8s-backend + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: rhdh-k8s-backend + namespace: {{ .Values.pipelines.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["create", "get"] + - apiGroups: ["tekton.dev"] + resources: ["pipelineruns"] + verbs: ["create", "get", "list"] + - apiGroups: ["kubevirt.io"] + resources: ["virtualmachines"] + verbs: ["get"] + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["get"] + - apiGroups: ["route.openshift.io"] + resources: ["routes"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: rhdh-k8s-backend + namespace: {{ .Values.pipelines.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: rhdh-k8s-backend +subjects: + - kind: ServiceAccount + name: rhdh-k8s-backend + namespace: {{ .Values.namespace }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: rhdh-catalog-proxy + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: rhdh-catalog-proxy + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +rules: + - apiGroups: [""] + resources: ["configmaps"] + resourceNames: ["rhdh-sandbox-entities"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: rhdh-catalog-proxy + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: rhdh-catalog-proxy +subjects: + - kind: ServiceAccount + name: rhdh-catalog-proxy + namespace: {{ .Values.namespace }} diff --git a/charts/openshell-rhdh/templates/secret-oidc.yaml b/charts/openshell-rhdh/templates/secret-oidc.yaml new file mode 100644 index 00000000..ec800b1c --- /dev/null +++ b/charts/openshell-rhdh/templates/secret-oidc.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Secret +metadata: + name: rhdh-oidc-credentials + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +type: Opaque +stringData: + {{- $existing := lookup "v1" "Secret" .Release.Namespace "rhdh-oidc-credentials" }} + {{- $sessionSecret := randAlphaNum 32 }} + {{- if and $existing $existing.data (index $existing.data "SESSION_SECRET") }} + {{- $sessionSecret = index $existing.data "SESSION_SECRET" | b64dec }} + {{- end }} + SESSION_SECRET: {{ $sessionSecret | quote }} + AUTH_OIDC_METADATA_URL: "https://{{ include "openshell-rhdh.keycloakHost" . }}/realms/{{ .Values.keycloak.realm }}/.well-known/openid-configuration" + AUTH_OIDC_CLIENT_ID: {{ .Values.keycloak.clientId | quote }} + AUTH_OIDC_CLIENT_SECRET: {{ .Values.keycloak.clientSecret | quote }} + RHDH_BASE_URL: {{ include "openshell-rhdh.baseUrl" . | quote }} diff --git a/charts/openshell-rhdh/templates/tekton-pipeline-create.yaml b/charts/openshell-rhdh/templates/tekton-pipeline-create.yaml new file mode 100644 index 00000000..6c457045 --- /dev/null +++ b/charts/openshell-rhdh/templates/tekton-pipeline-create.yaml @@ -0,0 +1,127 @@ +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: saw-create-sandbox + namespace: {{ .Values.pipelines.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +spec: + params: + - name: sandbox-name + type: string + - name: owner + type: string + - name: provider + type: string + - name: model + type: string + - name: oidc-issuer-url + type: string + default: "" + - name: repo-url + type: string + default: {{ .Values.pipelines.repoUrl | default "https://github.com/validatedpatterns-sandbox/secure-agent-workspace.git" | quote }} + - name: repo-ref + type: string + default: {{ .Values.pipelines.repoRef | default "main" | quote }} + tasks: + - name: helm-install + params: + - name: sandbox-name + value: $(params.sandbox-name) + - name: owner + value: $(params.owner) + - name: provider + value: $(params.provider) + - name: model + value: $(params.model) + - name: oidc-issuer-url + value: $(params.oidc-issuer-url) + - name: repo-url + value: $(params.repo-url) + - name: repo-ref + value: $(params.repo-ref) + taskSpec: + params: + - name: sandbox-name + type: string + - name: owner + type: string + - name: provider + type: string + - name: model + type: string + - name: oidc-issuer-url + type: string + - name: repo-url + type: string + - name: repo-ref + type: string + steps: + - name: install + image: alpine/k8s:1.31.3 + env: + - name: SANDBOX_NAME + value: "$(params.sandbox-name)" + - name: OWNER + value: "$(params.owner)" + - name: PROVIDER + value: "$(params.provider)" + - name: MODEL + value: "$(params.model)" + - name: OIDC_ISSUER_URL + value: "$(params.oidc-issuer-url)" + - name: REPO_URL + value: "$(params.repo-url)" + - name: REPO_REF + value: "$(params.repo-ref)" + script: | + #!/bin/sh + set -e + RELEASE="saw-${SANDBOX_NAME}" + NS="{{ .Values.pipelines.namespace }}" + + echo "Cloning chart from ${REPO_URL} @ ${REPO_REF}..." + apk add --no-cache git >/dev/null 2>&1 + git clone --depth=1 --branch "${REPO_REF}" "${REPO_URL}" /tmp/saw-repo + + OIDC_OPTS="" + if [ -n "${OIDC_ISSUER_URL}" ]; then + OIDC_OPTS="--set oidc.issuerUrl=${OIDC_ISSUER_URL}" + fi + + OPENSHELL_OPTS="" + {{- if .Values.openshell.gatewayImage }} + OPENSHELL_OPTS="$OPENSHELL_OPTS --set openshell.gatewayImage={{ .Values.openshell.gatewayImage }}" + {{- end }} + {{- if .Values.openshell.supervisorImage }} + OPENSHELL_OPTS="$OPENSHELL_OPTS --set openshell.supervisorImage={{ .Values.openshell.supervisorImage }}" + {{- end }} + {{- if .Values.openshell.version }} + OPENSHELL_OPTS="$OPENSHELL_OPTS --set openshell.version={{ .Values.openshell.version }}" + {{- end }} + {{- if .Values.openshell.pipIndexUrl }} + OPENSHELL_OPTS="$OPENSHELL_OPTS --set openshell.pipIndexUrl={{ .Values.openshell.pipIndexUrl }}" + {{- end }} + + if helm status "$RELEASE" -n "$NS" >/dev/null 2>&1; then + echo "ERROR: Sandbox ${SANDBOX_NAME} already exists. Aborting." + exit 1 + fi + + echo "Installing sandbox ${SANDBOX_NAME} for ${OWNER}..." + helm install "$RELEASE" /tmp/saw-repo/charts/openshell-saw \ + --namespace "$NS" \ + --set sandboxName="${SANDBOX_NAME}" \ + --set accessControl.owner="${OWNER}" \ + --set inference.provider="${PROVIDER}" \ + --set inference.model="${MODEL}" \ + --set inference.secretName="saw-${SANDBOX_NAME}-inference-key" \ + --set route.dashboard=true \ + --set governance.enabled=false \ + --set dashboard.enabled=false \ + $OIDC_OPTS \ + $OPENSHELL_OPTS \ + --wait --timeout=10m + + echo "Sandbox ${SANDBOX_NAME} provisioned." diff --git a/charts/openshell-rhdh/templates/tekton-pipeline-delete.yaml b/charts/openshell-rhdh/templates/tekton-pipeline-delete.yaml new file mode 100644 index 00000000..bc84e562 --- /dev/null +++ b/charts/openshell-rhdh/templates/tekton-pipeline-delete.yaml @@ -0,0 +1,100 @@ +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: saw-delete-sandbox + namespace: {{ .Values.pipelines.namespace }} + labels: + app.kubernetes.io/part-of: openshell-rhdh +spec: + params: + - name: sandbox-name + type: string + - name: owner + type: string + tasks: + - name: verify-and-delete + params: + - name: sandbox-name + value: $(params.sandbox-name) + - name: owner + value: $(params.owner) + taskSpec: + params: + - name: sandbox-name + type: string + - name: owner + type: string + steps: + - name: verify-owner + image: alpine/k8s:1.31.3 + env: + - name: SANDBOX_NAME + value: "$(params.sandbox-name)" + - name: OWNER + value: "$(params.owner)" + script: | + #!/bin/sh + set -e + RELEASE="saw-${SANDBOX_NAME}" + NS="{{ .Values.pipelines.namespace }}" + + ACTUAL_OWNER=$(helm get values "$RELEASE" -n "$NS" -o json 2>/dev/null \ + | python3 -c "import json,sys; print(json.load(sys.stdin).get('accessControl',{}).get('owner',''))" 2>/dev/null || echo "") + + if [ -z "$ACTUAL_OWNER" ]; then + echo "ERROR: Sandbox ${SANDBOX_NAME} not found" + exit 1 + fi + if [ "$ACTUAL_OWNER" != "${OWNER}" ]; then + echo "ERROR: Sandbox owned by '$ACTUAL_OWNER', not '${OWNER}'" + exit 1 + fi + echo "Owner verified: ${OWNER}" + - name: helm-uninstall + image: alpine/k8s:1.31.3 + env: + - name: SANDBOX_NAME + value: "$(params.sandbox-name)" + script: | + #!/bin/sh + set -e + RELEASE="saw-${SANDBOX_NAME}" + NS="{{ .Values.pipelines.namespace }}" + + echo "Deleting sandbox ${SANDBOX_NAME}..." + helm uninstall "$RELEASE" -n "$NS" --wait || true + + echo "Cleaning up resources..." + kubectl -n "$NS" delete pvc -l openshell.io/sandbox="${SANDBOX_NAME}" --ignore-not-found + kubectl -n "$NS" delete secret "saw-${SANDBOX_NAME}-inference-key" --ignore-not-found + + echo "Removing catalog entity..." + RHDH_NS="{{ .Values.namespace }}" + for attempt in 1 2 3; do + CM=$(kubectl -n "$RHDH_NS" get configmap rhdh-sandbox-entities -o json 2>/dev/null || echo "") + if [ -z "$CM" ]; then + echo "Catalog ConfigMap not found, skipping." + break + fi + CATALOG=$(echo "$CM" | jq -r '.data["catalog.yaml"] // ""') + if [ -z "$CATALOG" ]; then + echo "Catalog is empty, nothing to remove." + break + fi + CLEANED=$(printf '%s\n' "$CATALOG" | awk -v name="saw-${SANDBOX_NAME}" ' + BEGIN {skip=0; buf=""} + /^---/ { + if (!skip && buf != "") print buf + buf="---"; skip=0; next + } + /^ name: / {if ($2 == name) skip=1} + {if (buf != "") buf=buf "\n" $0; else buf=$0} + END {if (!skip && buf != "") print buf} + ') + echo "$CM" | jq --arg c "$CLEANED" '.data["catalog.yaml"]=$c' | \ + kubectl replace -f - && break + echo "Conflict on attempt $attempt, retrying..." + sleep 1 + done + + echo "Sandbox ${SANDBOX_NAME} deleted." diff --git a/charts/openshell-rhdh/values.yaml b/charts/openshell-rhdh/values.yaml new file mode 100644 index 00000000..2853b399 --- /dev/null +++ b/charts/openshell-rhdh/values.yaml @@ -0,0 +1,32 @@ +namespace: rhdh + +appsDomain: "" + +baseUrl: "" + +keycloak: + host: "" + realm: openshell + clientId: rhdh + clientSecret: "" + keycloakName: openshell-keycloak + keycloakNamespace: "" + +pipelines: + namespace: openshell-agents + repoUrl: "" + repoRef: main + +provisioner: + serviceAccount: saw-provisioner + +openshell: + gatewayImage: "" + supervisorImage: "" + version: "" + pipIndexUrl: "" + +# NOTE: This chart requires a pre-existing Secret 'rhdh-k8s-tokens' in the +# RHDH namespace containing K8S_API_TOKEN (for rhdh-k8s-backend SA), +# K8S_CATALOG_TOKEN (for rhdh-catalog-proxy SA), and K8S_API_URL. +# The quickstart path creates this via 'make deploy-rhdh'. diff --git a/charts/openshell-saw/templates/configmap-scripts.yaml b/charts/openshell-saw/templates/configmap-scripts.yaml index e49cafe9..c3a851e2 100644 --- a/charts/openshell-saw/templates/configmap-scripts.yaml +++ b/charts/openshell-saw/templates/configmap-scripts.yaml @@ -369,12 +369,12 @@ data: echo "Restarting gateway service..." guest_ssh "systemctl --user restart openshell-gateway.service" || true GW_READY=0 - for i in $(seq 1 10); do + for i in $(seq 1 30); do if guest_ssh "systemctl --user is-active openshell-gateway.service" 2>/dev/null; then GW_READY=1; break fi echo " waiting for gateway... (attempt $i)" - sleep 3 + sleep 5 done if [[ "${GW_READY}" -ne 1 ]]; then echo "ERROR: gateway failed to start after binary install" >&2 @@ -1147,6 +1147,31 @@ data: exit 0 fi + GATEWAY_NAME="${OPENSHELL_GATEWAY:-openshell}" + MTLS_GW_NAME="openshell-local" + + if [[ -n "${OIDC_TOKEN:-}" ]]; then + OIDC_TOKEN_PATH="${HOME}/.config/openshell/gateways/${GATEWAY_NAME}/oidc_token.json" + mkdir -p "$(dirname "${OIDC_TOKEN_PATH}")" + chmod 700 "$(dirname "${OIDC_TOKEN_PATH}")" + cat > "${OIDC_TOKEN_PATH}" </dev/null || true + openshell gateway add https://127.0.0.1:17670 --name "${MTLS_GW_NAME}" --local 2>/dev/null || true + + if [[ -n "${OIDC_TOKEN:-}" ]]; then + openshell gateway select "${GATEWAY_NAME}" >/dev/null 2>&1 || true + openshell workspace member add --workspace default \ + --subject openshell-client --role admin 2>&1 || \ + echo "WARN: could not pre-grant workspace access via OIDC" + fi + + openshell gateway select "${MTLS_GW_NAME}" >/dev/null 2>&1 || true + for WS_DIR in "${WS_BASE}"/*/; do [[ -f "${WS_DIR}/workspace.yaml" ]] || continue WS_BOM="$(basename "${WS_DIR}")" diff --git a/values-prod.yaml b/values-prod.yaml index 943641f8..1404d732 100644 --- a/values-prod.yaml +++ b/values-prod.yaml @@ -13,6 +13,7 @@ clusterGroup: operatorGroup: true targetNamespaces: - openshell-agents + rhdh: rhdh-operator: operatorGroup: true targetNamespaces: [] @@ -79,6 +80,10 @@ clusterGroup: name: governance-interceptor namespace: openshell-agents path: charts/governance-interceptor + openshell-rhdh: + name: openshell-rhdh + namespace: rhdh + path: charts/openshell-rhdh openshell-saw: name: openshell-saw namespace: openshell-agents