Repository: rancher-sandbox/rancher-desktop Branch: main Commit: eb39a0cdd18a Files: 994 Total size: 4.8 MB Directory structure: gitextract_qqauqqo8/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── .yamlfmt │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── actions/ │ │ ├── get-token/ │ │ │ └── action.yaml │ │ ├── setup-environment/ │ │ │ └── action.yaml │ │ ├── spelling/ │ │ │ ├── README.md │ │ │ ├── advice.md │ │ │ ├── allow.txt │ │ │ ├── candidate.patterns │ │ │ ├── excludes.txt │ │ │ ├── expect.txt │ │ │ ├── line_forbidden.patterns │ │ │ ├── patterns.txt │ │ │ └── reject.txt │ │ └── yarn-install/ │ │ └── action.yaml │ ├── dependabot.yml │ └── workflows/ │ ├── bats/ │ │ ├── get-tests.py │ │ ├── sanitize-artifact-name.sh │ │ └── summarize.mjs │ ├── bats.yaml │ ├── codeql.yaml │ ├── docker-cli-monitor.yaml │ ├── k3s-versions.yaml │ ├── linux-e2e.yaml │ ├── linux-release.yaml │ ├── macM1-e2e.yaml │ ├── package.yaml │ ├── paths-ignore.yaml │ ├── rddepman.yaml │ ├── rdx-host-api-tests.yaml │ ├── release-merge-to-main.yaml │ ├── scorecard.yml │ ├── screenshot.yaml │ ├── smoke-test/ │ │ ├── install-from-repo.sh │ │ └── smoke-test.sh │ ├── smoke-test.yaml │ ├── spelling.yml │ ├── test.yaml │ ├── ucmonitor.yaml │ ├── upgrade-generate.yaml │ ├── windows-e2e.yaml │ └── yarn-dedupe.yaml ├── .gitignore ├── .gitmodules ├── .golangci.yaml ├── .yarnrc.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.cjs ├── background.ts ├── bats/ │ ├── Makefile │ ├── README.md │ ├── scripts/ │ │ ├── bats-lint.pl │ │ └── ghcr-mirror.sh │ └── tests/ │ ├── compose/ │ │ ├── compose.bats │ │ └── testdata/ │ │ ├── Dockerfile.nginx │ │ ├── app/ │ │ │ ├── Dockerfile │ │ │ ├── app.py │ │ │ └── requirements.txt │ │ ├── compose.yaml │ │ └── nginx.conf │ ├── containers/ │ │ ├── allowed-images.bats │ │ ├── auto-start.bats │ │ ├── catch-duplicate-api-patterns.bats │ │ ├── docker-buildx-python3-uname.bats │ │ ├── factory-reset-containerd-shims.bats │ │ ├── factory-reset-snapshots.bats │ │ ├── factory-reset.bats │ │ ├── host-connectivity.bats │ │ ├── host-network-ports.bats │ │ ├── init.bats │ │ ├── platform.bats │ │ ├── published-ports.bats │ │ ├── published-udp-ports.bats │ │ ├── reset.bats │ │ ├── run-rancher.bats │ │ ├── split-dns-vpn.bats │ │ ├── switch-engines.bats │ │ ├── volumes.bats │ │ └── wasm.bats │ ├── extensions/ │ │ ├── allow-list.bats │ │ ├── containers.bats │ │ ├── install.bats │ │ └── testdata/ │ │ ├── Dockerfile │ │ ├── Makefile │ │ ├── README.md │ │ ├── basic.json │ │ ├── bin/ │ │ │ ├── dummy.go │ │ │ ├── dummy.sh │ │ │ └── server.go │ │ ├── compose.yaml │ │ ├── everything.json │ │ ├── host-apis.json │ │ ├── host-binaries.json │ │ ├── missing-icon-file.json │ │ ├── missing-icon.json │ │ ├── ui/ │ │ │ ├── host-apis.html │ │ │ └── index.html │ │ ├── ui.json │ │ ├── vm-compose.json │ │ └── vm-image.json │ ├── helpers/ │ │ ├── commands.bash │ │ ├── defaults.bash │ │ ├── images.bash │ │ ├── info.bash │ │ ├── kubernetes.bash │ │ ├── kubernetes.bats │ │ ├── load.bash │ │ ├── os.bash │ │ ├── paths.bash │ │ ├── profile.bash │ │ ├── snapshots.bash │ │ ├── utils.bash │ │ ├── utils.bats │ │ └── vm.bash │ ├── k8s/ │ │ ├── enable-disable-k8s.bats │ │ ├── foreach-k3s-version.bats │ │ ├── helm-install-rancher.bats │ │ ├── port-forwarding.bats │ │ ├── specify-invalid-k8s-version.bats │ │ ├── spinkube-npm.bats │ │ ├── spinkube.bats │ │ ├── traefik.bats │ │ ├── up-downgrade-k8s.bats │ │ └── wasm.bats │ ├── preferences/ │ │ ├── move-from-roaming-to-local.bats │ │ ├── surface-invalid-args.bats │ │ ├── verify-paths.bats │ │ └── verify-settings.bats │ ├── profile/ │ │ ├── create-profile-output.bats │ │ ├── deployment.bats │ │ ├── invalid-locked-k8s-version.bats │ │ └── wasm.bats │ ├── registry/ │ │ └── creds.bats │ ├── snapshots/ │ │ ├── create-use-snapshot.bats │ │ ├── restore-snapshot-after-factory-reset.bats │ │ ├── test-snapshot-list.bats │ │ └── test_rdctl_snapshot.bats │ └── utils/ │ ├── rdctl.bats │ └── spin.bats ├── build/ │ ├── electron-publisher-custom.js │ ├── license.rtf │ ├── signing-config-mac.yaml │ ├── signing-config-win.yaml │ └── wix/ │ ├── dialogs.wxs │ ├── main.wxs │ ├── scope.wxs │ ├── string-overrides.wxl │ ├── verify.wxs │ └── welcome.wxs ├── dev-app-update.yml ├── docs/ │ ├── development/ │ │ ├── README.md │ │ ├── env.md │ │ ├── factory-reset.md │ │ ├── features.md │ │ ├── linux-release-process.md │ │ ├── obs.md │ │ ├── release-checklist.md │ │ └── signing.md │ └── networking/ │ └── windows/ │ ├── README.md │ ├── rancher-desktop-guest-agent.md │ └── rancher-desktop-networking.md ├── e2e/ │ ├── assets/ │ │ └── k8s-deploy-sample/ │ │ └── nginx-sample-app.yaml │ ├── backend.e2e.spec.ts │ ├── config/ │ │ └── playwright-config.ts │ ├── containers.e2e.spec.ts │ ├── credentials-server.e2e.spec.ts │ ├── extensions.e2e.spec.ts │ ├── lockedFields.e2e.spec.ts │ ├── main.e2e.spec.ts │ ├── pages/ │ │ ├── container-logs-page.ts │ │ ├── container-shell-page.ts │ │ ├── containers-page.ts │ │ ├── diagnostics-page.ts │ │ ├── extensions-page.ts │ │ ├── images-page.ts │ │ ├── k8s-page.ts │ │ ├── nav-page.ts │ │ ├── portforward-page.ts │ │ ├── preferences/ │ │ │ ├── application.ts │ │ │ ├── containerEngine.ts │ │ │ ├── index.ts │ │ │ ├── kubernetes.ts │ │ │ ├── virtualMachine.ts │ │ │ └── wsl.ts │ │ ├── snapshots-page.ts │ │ ├── troubleshooting-page.ts │ │ ├── volumes-page.ts │ │ └── wsl-integrations-page.ts │ ├── preferences.e2e.spec.ts │ ├── quit-on-close.e2e.spec.ts │ ├── rdctl.e2e.spec.ts │ ├── start-in-background.e2e.spec.ts │ ├── startup-profiles.e2e.spec.ts │ ├── utils/ │ │ ├── ProfileUtils.ts │ │ └── TestUtils.ts │ ├── volumes.e2e.spec.ts │ └── wsl-integrations.e2e.spec.ts ├── eslint.config.mts ├── go.work ├── jest.config.js ├── package.json ├── packaging/ │ ├── electron-builder.yml │ └── linux/ │ ├── appimage.yml │ ├── flatpak.yaml │ ├── rancher-desktop.appdata.xml │ └── rancher-desktop.spec ├── pkg/ │ └── rancher-desktop/ │ ├── assets/ │ │ ├── dependencies.yaml │ │ ├── extension-data.yaml │ │ ├── lima-config.yaml │ │ ├── networks-config.yaml │ │ ├── scripts/ │ │ │ ├── 10-flannel.conflist │ │ │ ├── buildkit.confd │ │ │ ├── buildkit.initd │ │ │ ├── cert-manager.yaml │ │ │ ├── configure-allowed-images │ │ │ ├── docker-credential-rancher-desktop │ │ │ ├── install-containerd-shims │ │ │ ├── install-k3s │ │ │ ├── install-wsl-helpers │ │ │ ├── k3s-containerd-config.toml │ │ │ ├── logrotate-k3s │ │ │ ├── logrotate-lima-guestagent │ │ │ ├── logrotate-openresty │ │ │ ├── moproxy.initd │ │ │ ├── nerdctl │ │ │ ├── nginx.conf │ │ │ ├── rancher-desktop-guestagent.initd │ │ │ ├── service-cri-dockerd.initd │ │ │ ├── service-k3s.initd │ │ │ ├── service-wsl-dockerd.initd │ │ │ ├── spin-operator.yaml │ │ │ ├── wsl-data.conf │ │ │ ├── wsl-exec │ │ │ └── wsl-init │ │ ├── specs/ │ │ │ ├── README.md │ │ │ └── command-api.yaml │ │ ├── styles/ │ │ │ ├── app.scss │ │ │ ├── base/ │ │ │ │ ├── _basic.scss │ │ │ │ ├── _color.scss │ │ │ │ ├── _functions.scss │ │ │ │ ├── _helpers.scss │ │ │ │ ├── _mixins.scss │ │ │ │ ├── _typography.scss │ │ │ │ └── _variables.scss │ │ │ ├── fonts/ │ │ │ │ ├── _dots.scss │ │ │ │ ├── _fontstack.scss │ │ │ │ ├── _icons.scss │ │ │ │ └── _zerowidthspace.scss │ │ │ ├── global/ │ │ │ │ ├── _button.scss │ │ │ │ ├── _cards.scss │ │ │ │ ├── _columns.scss │ │ │ │ ├── _form.scss │ │ │ │ ├── _gauges.scss │ │ │ │ ├── _labeled-input.scss │ │ │ │ ├── _resource.scss │ │ │ │ ├── _select.scss │ │ │ │ ├── _table.scss │ │ │ │ └── _tooltip.scss │ │ │ ├── rancher-desktop.scss │ │ │ ├── themes/ │ │ │ │ ├── _dark.scss │ │ │ │ ├── _light.scss │ │ │ │ └── _suse.scss │ │ │ └── vendor/ │ │ │ ├── normalize.scss │ │ │ └── vue-select.scss │ │ └── translations/ │ │ ├── en-us.yaml │ │ └── zh-hans.yaml │ ├── backend/ │ │ ├── __tests__/ │ │ │ ├── backendHelper.spec.ts │ │ │ └── k3sHelper.spec.ts │ │ ├── backend.ts │ │ ├── backendHelper.ts │ │ ├── containerClient/ │ │ │ ├── __tests__/ │ │ │ │ ├── auth.spec.ts │ │ │ │ ├── client.spec.ts │ │ │ │ └── registry.spec.ts │ │ │ ├── auth.ts │ │ │ ├── index.ts │ │ │ ├── mobyClient.ts │ │ │ ├── nerdctlClient.ts │ │ │ ├── registry.ts │ │ │ └── types.ts │ │ ├── factory.ts │ │ ├── images/ │ │ │ ├── imageFactory.ts │ │ │ ├── imageProcessor.ts │ │ │ ├── mobyImageProcessor.ts │ │ │ └── nerdctlImageProcessor.ts │ │ ├── k3sHelper.ts │ │ ├── k8s.ts │ │ ├── kube/ │ │ │ ├── client.ts │ │ │ ├── lima.ts │ │ │ └── wsl.ts │ │ ├── kubeconfig.ts │ │ ├── lima.ts │ │ ├── mock.ts │ │ ├── mock_screenshots.ts │ │ ├── progressTracker.ts │ │ ├── steve.ts │ │ └── wsl.ts │ ├── components/ │ │ ├── ActionDropdown.vue │ │ ├── ActionMenu.vue │ │ ├── Alert.vue │ │ ├── AsyncButton.vue │ │ ├── BackendProgress.vue │ │ ├── ContainerLogs.vue │ │ ├── ContainerShell.vue │ │ ├── ContainerStatusBadge.vue │ │ ├── DashboardOpen.vue │ │ ├── DiagnosticsBody.vue │ │ ├── DiagnosticsButtonRun.vue │ │ ├── EmptyState.vue │ │ ├── EngineSelector.vue │ │ ├── ExtensionsError.vue │ │ ├── ExtensionsUninstalled.vue │ │ ├── Help.vue │ │ ├── ImageAddTabs.vue │ │ ├── Images.vue │ │ ├── ImagesButtonAdd.vue │ │ ├── ImagesFormAdd.vue │ │ ├── ImagesOutputWindow.vue │ │ ├── ImagesScanResults.vue │ │ ├── IncompatiblePreferencesAlert.vue │ │ ├── LoadingIndicator.vue │ │ ├── MarketplaceCard.vue │ │ ├── MarketplaceCatalog.vue │ │ ├── MountTypeSelector.vue │ │ ├── Nav.vue │ │ ├── NavIconExtension.vue │ │ ├── NavItem.vue │ │ ├── NetworkStatus.vue │ │ ├── Notifications.vue │ │ ├── PathManagementSelector.vue │ │ ├── PortForwarding.vue │ │ ├── Preferences/ │ │ │ ├── Alert.vue │ │ │ ├── ApplicationBehavior.vue │ │ │ ├── ApplicationEnvironment.vue │ │ │ ├── ApplicationGeneral.vue │ │ │ ├── BodyApplication.vue │ │ │ ├── BodyContainerEngine.vue │ │ │ ├── BodyKubernetes.vue │ │ │ ├── BodyVirtualMachine.vue │ │ │ ├── BodyWsl.vue │ │ │ ├── ButtonOpen.vue │ │ │ ├── ContainerEngineAllowedImages.vue │ │ │ ├── ContainerEngineGeneral.vue │ │ │ ├── Help.vue │ │ │ ├── ModalBody.vue │ │ │ ├── ModalFooter.vue │ │ │ ├── ModalHeader.vue │ │ │ ├── ModalNav.vue │ │ │ ├── ModalNavItem.vue │ │ │ ├── VirtualMachineEmulation.vue │ │ │ ├── VirtualMachineHardware.vue │ │ │ ├── VirtualMachineVolumes.vue │ │ │ ├── WslIntegrations.vue │ │ │ └── WslProxy.vue │ │ ├── RdInput.vue │ │ ├── RdProgress.vue │ │ ├── RdSelect.vue │ │ ├── SnapshotCard.vue │ │ ├── Snapshots.vue │ │ ├── SnapshotsButtonCreate.vue │ │ ├── SortableTable/ │ │ │ ├── THead.vue │ │ │ ├── actions.js │ │ │ ├── advanced-filtering.js │ │ │ ├── debug.js │ │ │ ├── filtering.js │ │ │ ├── grouping.js │ │ │ ├── index.vue │ │ │ ├── paging.js │ │ │ ├── selection.js │ │ │ ├── sortable-config.ts │ │ │ └── sorting.js │ │ ├── StatusBar.vue │ │ ├── StatusBarItem.vue │ │ ├── SystemPreferences.vue │ │ ├── Tabbed/ │ │ │ ├── RdTabbed.vue │ │ │ ├── Tab.vue │ │ │ └── index.vue │ │ ├── TelemetryOptIn.vue │ │ ├── TheTitle.vue │ │ ├── TroubleshootingLineItem.vue │ │ ├── UpdateStatus.vue │ │ ├── Version.vue │ │ ├── WSLIntegration.vue │ │ ├── __tests__/ │ │ │ ├── BackendProgress.spec.ts │ │ │ ├── PreferencesButton.spec.ts │ │ │ ├── StatusBar.spec.ts │ │ │ ├── SystemPreferences.spec.js │ │ │ └── UpdateStatus.spec.ts │ │ └── form/ │ │ ├── LabeledBadge.vue │ │ ├── LabeledSelect.vue │ │ ├── LabeledTooltip.vue │ │ ├── RdCheckbox.vue │ │ ├── RdFieldset.vue │ │ ├── RdSlider.vue │ │ ├── SplitButton.vue │ │ ├── TextAreaAutoGrow.vue │ │ ├── TooltipIcon.vue │ │ ├── __tests__/ │ │ │ └── SplitButton.spec.ts │ │ └── labeled-select-utils/ │ │ └── labeled-select-pagination.ts │ ├── config/ │ │ ├── __tests__/ │ │ │ ├── commandLineOptions.spec.ts │ │ │ ├── settings.spec.ts │ │ │ └── settingsMigrations.spec.ts │ │ ├── commandLineOptions.ts │ │ ├── cookies.js │ │ ├── emptyStubForJSLinter.js │ │ ├── help.ts │ │ ├── private-label.js │ │ ├── query-params.js │ │ ├── settings.ts │ │ ├── settingsImpl.ts │ │ ├── transientSettings.ts │ │ └── types.js │ ├── entry/ │ │ ├── README.md │ │ ├── index.ts │ │ ├── plugins.ts │ │ ├── router.ts │ │ └── store.ts │ ├── hocs/ │ │ ├── README.md │ │ └── withCredentials.ts │ ├── index.ts │ ├── integrations/ │ │ ├── __tests__/ │ │ │ ├── manageLinesInFile.spec.ts │ │ │ ├── pathManager.spec.ts │ │ │ ├── unixIntegrationManager.spec.ts │ │ │ └── windowsIntegrationManager.spec.ts │ │ ├── integrationManager.ts │ │ ├── manageLinesInFile.ts │ │ ├── pathManager.ts │ │ ├── pathManagerImpl.ts │ │ ├── unixIntegrationManager.ts │ │ └── windowsIntegrationManager.ts │ ├── layouts/ │ │ ├── default.vue │ │ ├── dialog.vue │ │ └── preferences.vue │ ├── main/ │ │ ├── __tests__/ │ │ │ ├── containerExec.spec.ts │ │ │ ├── deploymentProfiles.spec.ts │ │ │ └── ipcMain.spec.ts │ │ ├── commandServer/ │ │ │ ├── __tests__/ │ │ │ │ └── settingsValidator.spec.ts │ │ │ ├── httpCommandServer.ts │ │ │ └── settingsValidator.ts │ │ ├── containerExec.ts │ │ ├── credentialServer/ │ │ │ ├── README.md │ │ │ ├── __tests__/ │ │ │ │ └── credentialUtils.spec.ts │ │ │ ├── credentialUtils.ts │ │ │ └── httpCredentialHelperServer.ts │ │ ├── dashboardServer/ │ │ │ ├── index.ts │ │ │ └── proxyUtils.ts │ │ ├── deploymentProfiles.ts │ │ ├── diagnostics/ │ │ │ ├── __tests__/ │ │ │ │ ├── diagnostics.spec.ts │ │ │ │ ├── dockerCliSymlinks.spec.ts │ │ │ │ └── rdBinInShell.spec.ts │ │ │ ├── connectedToInternet.ts │ │ │ ├── diagnostics.ts │ │ │ ├── dockerCliSymlinks.ts │ │ │ ├── dockerContext.ts │ │ │ ├── integrationsWindows.ts │ │ │ ├── kubeConfigSymlink.ts │ │ │ ├── kubeContext.ts │ │ │ ├── kubeVersionsAvailable.ts │ │ │ ├── limaDarwin.ts │ │ │ ├── limaOverrides.ts │ │ │ ├── mobyImageStore.ts │ │ │ ├── mockForScreenshots.ts │ │ │ ├── pathManagement.ts │ │ │ ├── rdBinInShell.ts │ │ │ ├── testCheckers.ts │ │ │ ├── types.ts │ │ │ ├── wslDistros.ts │ │ │ └── wslInfo.ts │ │ ├── extensions/ │ │ │ ├── __tests__/ │ │ │ │ ├── extensions.spec.ts │ │ │ │ └── manager.spec.ts │ │ │ ├── extensions.ts │ │ │ ├── index.ts │ │ │ ├── manager.ts │ │ │ └── types.ts │ │ ├── imageEvents.ts │ │ ├── ipcMain.ts │ │ ├── mainEvents.ts │ │ ├── mainmenu.ts │ │ ├── networking/ │ │ │ ├── __tests__/ │ │ │ │ └── mac-ca.spec.ts │ │ │ ├── cert-parse.ts │ │ │ ├── index.ts │ │ │ ├── linux-ca.ts │ │ │ ├── mac-ca.ts │ │ │ ├── proxy.ts │ │ │ └── win-ca.ts │ │ ├── serverHelper.ts │ │ ├── snapshots/ │ │ │ ├── snapshots.ts │ │ │ └── types.ts │ │ ├── tray.ts │ │ └── update/ │ │ ├── LonghornProvider.ts │ │ ├── MSIUpdater.ts │ │ ├── __tests__/ │ │ │ └── LonghornProvider.spec.ts │ │ └── index.ts │ ├── middleware/ │ │ ├── i18n.js │ │ └── indexRedirect.js │ ├── mixins/ │ │ ├── compact-input.ts │ │ ├── labeled-form-element.ts │ │ └── vue-select-overrides.js │ ├── pages/ │ │ ├── Containers.vue │ │ ├── DenyRoot.vue │ │ ├── Diagnostics.vue │ │ ├── Dialog.vue │ │ ├── Extensions.vue │ │ ├── FirstRun.vue │ │ ├── General.vue │ │ ├── Images.vue │ │ ├── KubernetesError.vue │ │ ├── PortForwarding.vue │ │ ├── Preferences.vue │ │ ├── Snapshots.vue │ │ ├── SudoPrompt.vue │ │ ├── Troubleshooting.vue │ │ ├── UnmetPrerequisites.vue │ │ ├── Volumes.vue │ │ ├── containers/ │ │ │ └── ContainerInfo.vue │ │ ├── extensions/ │ │ │ ├── _root/ │ │ │ │ └── _src/ │ │ │ │ └── _id.vue │ │ │ └── installed.vue │ │ ├── images/ │ │ │ ├── add.vue │ │ │ └── scans/ │ │ │ └── _image-name.vue │ │ ├── snapshots/ │ │ │ ├── create.vue │ │ │ └── dialog.vue │ │ └── volumes/ │ │ └── files/ │ │ └── _name.vue │ ├── plugins/ │ │ ├── clean-html-directive.js │ │ ├── clean-tooltip-directive.ts │ │ ├── directives.js │ │ ├── i18n.js │ │ ├── shortkey.js │ │ ├── tooltip.ts │ │ ├── trim-whitespace.js │ │ └── v-select.js │ ├── preload/ │ │ ├── README.md │ │ ├── extensions.ts │ │ └── index.ts │ ├── product.js │ ├── public/ │ │ └── index.html │ ├── store/ │ │ ├── action-menu.js │ │ ├── applicationSettings.ts │ │ ├── container-engine.ts │ │ ├── credentials.ts │ │ ├── diagnostics.ts │ │ ├── extensions.ts │ │ ├── i18n.js │ │ ├── imageManager.ts │ │ ├── k8sManager.js │ │ ├── page.ts │ │ ├── preferences.ts │ │ ├── prefs.js │ │ ├── resource-fetch.js │ │ ├── snapshots.ts │ │ ├── transientSettings.ts │ │ └── ts-helpers.ts │ ├── sudo-prompt/ │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── index.d.ts │ │ ├── index.js │ │ ├── package.json │ │ ├── test-concurrent.js │ │ └── test.js │ ├── tsconfig.json │ ├── types/ │ │ └── components/ │ │ └── labeledSelect.ts │ ├── typings/ │ │ ├── assets.d.ts │ │ ├── electron-ipc.d.ts │ │ ├── linux-ca.d.ts │ │ ├── rdx.d.ts │ │ ├── shell.d.ts │ │ ├── shims-vue.d.ts │ │ ├── store.d.ts │ │ ├── unix.interface.ts │ │ └── vue-i18n.ts │ ├── utils/ │ │ ├── DownloadProgressListener.ts │ │ ├── __tests__/ │ │ │ ├── childProcess.spec.ts │ │ │ ├── dockerDirManager.spec.ts │ │ │ ├── dockerUtils.spec.ts │ │ │ ├── iterator.spec.ts │ │ │ ├── kubeVersions.spec.ts │ │ │ ├── paths.spec.ts │ │ │ └── safeRename.spec.ts │ │ ├── array.ts │ │ ├── backgroundProcess.ts │ │ ├── childProcess.ts │ │ ├── clone.ts │ │ ├── commandLine.ts │ │ ├── dateUtils.ts │ │ ├── dockerDirManager.ts │ │ ├── dockerUtils.ts │ │ ├── dom.js │ │ ├── environment.ts │ │ ├── eventEmitter.ts │ │ ├── filters.ts │ │ ├── imageOutputCuller.ts │ │ ├── ipcRenderer.ts │ │ ├── iterator.ts │ │ ├── kubeVersions.ts │ │ ├── latch.ts │ │ ├── logging.ts │ │ ├── networks.ts │ │ ├── object.js │ │ ├── osVersion.ts │ │ ├── paths.ts │ │ ├── platform.js │ │ ├── position.js │ │ ├── processOutputInterpreters/ │ │ │ ├── __tests__/ │ │ │ │ ├── assets/ │ │ │ │ │ ├── build.txt │ │ │ │ │ ├── pull.txt │ │ │ │ │ ├── pull03.txt │ │ │ │ │ ├── pull2.txt │ │ │ │ │ ├── push.txt │ │ │ │ │ ├── trivy-image-metric-server-input.txt │ │ │ │ │ ├── trivy-image-metric-server-output.txt │ │ │ │ │ ├── trivy-image-postgres-input.txt │ │ │ │ │ └── trivy-image-postgres-output.txt │ │ │ │ ├── image-build-output.spec.js │ │ │ │ ├── image-non-build-output.spec.js │ │ │ │ └── trivy-image-output.spec.js │ │ │ ├── image-build-output.ts │ │ │ ├── image-non-build-output.ts │ │ │ └── trivy-image-output.ts │ │ ├── protocols.ts │ │ ├── resources.ts │ │ ├── safeRename.ts │ │ ├── select.js │ │ ├── shortcuts.ts │ │ ├── sort.js │ │ ├── string-encode.ts │ │ ├── string.js │ │ ├── stringify.ts │ │ ├── testUtils/ │ │ │ ├── mockModules.ts │ │ │ ├── mockResources.ts │ │ │ ├── setupVue.ts │ │ │ └── vue-jest.js │ │ ├── type-helpers.ts │ │ ├── typeUtils.ts │ │ ├── units.js │ │ ├── version.ts │ │ ├── width.js │ │ └── wslVersion.ts │ ├── vue.config.mjs │ └── window/ │ ├── constants.ts │ ├── dashboard.ts │ ├── index.ts │ ├── preferenceConstants.ts │ └── preferences.ts ├── resources/ │ ├── k3s-versions.json │ └── setup-spin ├── screenshots/ │ ├── README.md │ ├── Screenshots.ts │ ├── playwright-config.ts │ ├── screenshot.ps1 │ ├── screenshots.e2e.spec.ts │ ├── set-display-resolution.ps1 │ └── test-data/ │ ├── containers.ts │ ├── images.ts │ ├── preferences.ts │ ├── snapshots.ts │ └── volumes.ts ├── scripts/ │ ├── assets/ │ │ ├── extension-data.yaml │ │ └── options.go.templ │ ├── build.ts │ ├── check-api-schema.ts │ ├── dependencies/ │ │ ├── go-source.ts │ │ ├── lima.ts │ │ ├── moby-openapi.ts │ │ ├── sudo-prompt.ts │ │ ├── tar-archives.ts │ │ ├── tools.ts │ │ ├── wix.ts │ │ └── wsl.ts │ ├── dev.ts │ ├── docker-cli-monitor.ts │ ├── e2e.ts │ ├── extension-data.ts │ ├── generateCliCode.ts │ ├── go-license-check.sh │ ├── go.mod │ ├── go.sum │ ├── install-latest-ci.sh │ ├── k3s-versions.go │ ├── k3s-versions.sh │ ├── lib/ │ │ ├── build-utils.ts │ │ ├── dependencies.ts │ │ ├── download.ts │ │ ├── extension-data.ts │ │ ├── installer-win32-gen.tsx │ │ ├── installer-win32.tsx │ │ ├── sign-macos.ts │ │ └── sign-win32.ts │ ├── lint-go.ts │ ├── lint-typescript.ts │ ├── package.ts │ ├── populate-update-server.ts │ ├── postinstall.ts │ ├── rddepman.ts │ ├── release-merge-to-main.ts │ ├── sign.ts │ ├── simple_process.ts │ ├── spelling.sh │ ├── ts-wrapper.js │ ├── unreleased-change-monitor.ts │ ├── windows/ │ │ ├── generate-nerdctl-stub.ps1 │ │ ├── install-wsl.ps1 │ │ ├── restart-helpers.ps1 │ │ ├── sudo-install-wsl.ps1 │ │ └── uninstall-wsl.ps1 │ ├── windows-setup.ps1 │ ├── wix.ts │ └── yarn-dedupe.sh ├── src/ │ ├── go/ │ │ ├── docker-credential-none/ │ │ │ ├── dcnone/ │ │ │ │ ├── dcnone.go │ │ │ │ ├── dcnone_test.go │ │ │ │ └── helpers.go │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── extension-proxy/ │ │ │ ├── README.md │ │ │ ├── go.mod │ │ │ └── main.go │ │ ├── guestagent/ │ │ │ ├── README.md │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ ├── main.go │ │ │ └── pkg/ │ │ │ ├── containerd/ │ │ │ │ ├── events_linux.go │ │ │ │ └── events_stub.go │ │ │ ├── docker/ │ │ │ │ └── events.go │ │ │ ├── forwarder/ │ │ │ │ ├── forwarder.go │ │ │ │ ├── serviceapi.go │ │ │ │ └── wslproxy.go │ │ │ ├── iptables/ │ │ │ │ ├── iptables.go │ │ │ │ ├── iptables_test.go │ │ │ │ └── scanner.go │ │ │ ├── kube/ │ │ │ │ ├── servicewatcher_linux.go │ │ │ │ ├── watcher_linux.go │ │ │ │ └── watcher_stub.go │ │ │ ├── procnet/ │ │ │ │ ├── scanner_linux.go │ │ │ │ └── scanner_stub.go │ │ │ ├── tracker/ │ │ │ │ ├── apitracker.go │ │ │ │ ├── apitracker_test.go │ │ │ │ ├── portstorage.go │ │ │ │ └── tracker.go │ │ │ ├── types/ │ │ │ │ ├── README.md │ │ │ │ └── portmapping.go │ │ │ └── utils/ │ │ │ └── utils.go │ │ ├── mock-wsl/ │ │ │ ├── README.md │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ ├── lock_file_other.go │ │ │ ├── lock_file_windows.go │ │ │ ├── mock-wsl.go │ │ │ └── schema.json │ │ ├── nerdctl-stub/ │ │ │ ├── README.md │ │ │ ├── command_handlers.go │ │ │ ├── command_handlers_test.go │ │ │ ├── debugging.go │ │ │ ├── debugging_stub.go │ │ │ ├── generate/ │ │ │ │ ├── README.md │ │ │ │ ├── go.mod │ │ │ │ ├── go.sum │ │ │ │ ├── main_linux.go │ │ │ │ └── main_stub.go │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ ├── main.go │ │ │ ├── main_linux.go │ │ │ ├── main_shared.go │ │ │ ├── main_shared_test.go │ │ │ ├── main_unsupported.go │ │ │ ├── main_windows.go │ │ │ ├── nerdctl_commands_generated.go │ │ │ ├── parse_args.go │ │ │ └── parse_args_test.go │ │ ├── networking/ │ │ │ ├── .github/ │ │ │ │ └── workflows/ │ │ │ │ ├── go.yaml │ │ │ │ └── release.yaml │ │ │ ├── .gitignore │ │ │ ├── LICENSE │ │ │ ├── Makefile │ │ │ ├── README.md │ │ │ ├── cmd/ │ │ │ │ ├── host/ │ │ │ │ │ ├── config_windows.go │ │ │ │ │ └── switch_windows.go │ │ │ │ ├── network/ │ │ │ │ │ └── setup_linux.go │ │ │ │ ├── proxy/ │ │ │ │ │ └── wsl_integration_linux.go │ │ │ │ └── vm/ │ │ │ │ └── switch_linux.go │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── pkg/ │ │ │ ├── config/ │ │ │ │ └── config.go │ │ │ ├── log/ │ │ │ │ └── log.go │ │ │ ├── portproxy/ │ │ │ │ ├── server.go │ │ │ │ └── server_test.go │ │ │ ├── utils/ │ │ │ │ └── pipe.go │ │ │ └── vsock/ │ │ │ ├── conn_windows.go │ │ │ ├── constants.go │ │ │ └── handshake_windows.go │ │ ├── rdctl/ │ │ │ ├── README.md │ │ │ ├── cmd/ │ │ │ │ ├── api.go │ │ │ │ ├── createProfile.go │ │ │ │ ├── enum.go │ │ │ │ ├── extension.go │ │ │ │ ├── extensionInstall.go │ │ │ │ ├── extensionList.go │ │ │ │ ├── extensionUninstall.go │ │ │ │ ├── factoryReset.go │ │ │ │ ├── info.go │ │ │ │ ├── internal.go │ │ │ │ ├── internalProcess.go │ │ │ │ ├── internalProcessWaitKill.go │ │ │ │ ├── listSettings.go │ │ │ │ ├── paths.go │ │ │ │ ├── reset.go │ │ │ │ ├── root.go │ │ │ │ ├── set.go │ │ │ │ ├── setup.go │ │ │ │ ├── shell.go │ │ │ │ ├── shutdown.go │ │ │ │ ├── snapshot.go │ │ │ │ ├── snapshotCreate.go │ │ │ │ ├── snapshotDelete.go │ │ │ │ ├── snapshotList.go │ │ │ │ ├── snapshotList_test.go │ │ │ │ ├── snapshotRestore.go │ │ │ │ ├── snapshotUnlock.go │ │ │ │ ├── start.go │ │ │ │ └── version.go │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ ├── main.go │ │ │ └── pkg/ │ │ │ ├── autostart/ │ │ │ │ ├── autostart_darwin.go │ │ │ │ ├── autostart_linux.go │ │ │ │ └── autostart_windows.go │ │ │ ├── client/ │ │ │ │ ├── client.go │ │ │ │ ├── handle_unix.go │ │ │ │ ├── handle_windows.go │ │ │ │ └── utils.go │ │ │ ├── command/ │ │ │ │ └── command.go │ │ │ ├── config/ │ │ │ │ ├── config.go │ │ │ │ └── config_test.go │ │ │ ├── directories/ │ │ │ │ ├── directories.go │ │ │ │ ├── directories_test.go │ │ │ │ ├── directories_windows.go │ │ │ │ ├── directories_windows_test.go │ │ │ │ ├── empty.go │ │ │ │ └── lima_home.go │ │ │ ├── factoryreset/ │ │ │ │ ├── delete_data.go │ │ │ │ ├── delete_data_darwin.go │ │ │ │ ├── delete_data_linux.go │ │ │ │ ├── delete_data_unix.go │ │ │ │ ├── delete_data_unix_test.go │ │ │ │ ├── delete_data_windows.go │ │ │ │ ├── factory_reset_unix.go │ │ │ │ └── factory_reset_windows.go │ │ │ ├── info/ │ │ │ │ ├── ipaddress.go │ │ │ │ ├── struct.go │ │ │ │ └── version.go │ │ │ ├── lima/ │ │ │ │ └── name.go │ │ │ ├── lock/ │ │ │ │ ├── lock.go │ │ │ │ └── mock.go │ │ │ ├── paths/ │ │ │ │ ├── paths.go │ │ │ │ ├── paths_darwin.go │ │ │ │ ├── paths_darwin_test.go │ │ │ │ ├── paths_linux.go │ │ │ │ ├── paths_linux_test.go │ │ │ │ ├── paths_test.go │ │ │ │ ├── paths_unix.go │ │ │ │ ├── paths_windows.go │ │ │ │ └── paths_windows_test.go │ │ │ ├── plist/ │ │ │ │ ├── plist.go │ │ │ │ └── plist_test.go │ │ │ ├── process/ │ │ │ │ ├── process_darwin.go │ │ │ │ ├── process_linux.go │ │ │ │ ├── process_test.go │ │ │ │ ├── process_unix.go │ │ │ │ ├── process_windows.go │ │ │ │ └── process_windows_test.go │ │ │ ├── reg/ │ │ │ │ ├── reg.go │ │ │ │ └── reg_test.go │ │ │ ├── runner/ │ │ │ │ ├── runner.go │ │ │ │ └── runner_test.go │ │ │ ├── shell/ │ │ │ │ └── shell.go │ │ │ ├── shutdown/ │ │ │ │ └── shutdown.go │ │ │ ├── snapshot/ │ │ │ │ ├── copyFile_darwin.go │ │ │ │ ├── copyFile_linux.go │ │ │ │ ├── manager.go │ │ │ │ ├── manager_test.go │ │ │ │ ├── manager_unix_test.go │ │ │ │ ├── manager_windows_test.go │ │ │ │ ├── snapshot.go │ │ │ │ ├── snapshotter.go │ │ │ │ ├── snapshotter_unix.go │ │ │ │ └── snapshotter_windows.go │ │ │ ├── utils/ │ │ │ │ └── utils.go │ │ │ ├── version/ │ │ │ │ └── version.go │ │ │ └── wsl/ │ │ │ ├── doc.go │ │ │ ├── mock_windows.go │ │ │ ├── names.go │ │ │ └── wsl_windows.go │ │ ├── spin-stub/ │ │ │ ├── README.md │ │ │ ├── go.mod │ │ │ └── main.go │ │ ├── startup-profile/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ ├── main.go │ │ │ ├── model/ │ │ │ │ └── event.go │ │ │ ├── parsers/ │ │ │ │ ├── const.go │ │ │ │ ├── dmesg.go │ │ │ │ ├── interface.go │ │ │ │ ├── lima-ha.go │ │ │ │ ├── lima-init.go │ │ │ │ ├── networking.go │ │ │ │ ├── progress.go │ │ │ │ ├── rc.go │ │ │ │ ├── windows-guest-agent.go │ │ │ │ ├── windows-integration.go │ │ │ │ └── wsl-helper.go │ │ │ ├── rdctl/ │ │ │ │ └── rdctl.go │ │ │ ├── render/ │ │ │ │ ├── model.go │ │ │ │ ├── process.go │ │ │ │ └── render.go │ │ │ └── run.go │ │ └── wsl-helper/ │ │ ├── .gitignore │ │ ├── cmd/ │ │ │ ├── certificates_windows.go │ │ │ ├── dockerproxy.go │ │ │ ├── dockerproxy_kill_linux.go │ │ │ ├── dockerproxy_serve_linux.go │ │ │ ├── dockerproxy_serve_windows.go │ │ │ ├── dockerproxy_start.go │ │ │ ├── enum.go │ │ │ ├── k3s.go │ │ │ ├── k3s_kubeconfig.go │ │ │ ├── kubeconfig.go │ │ │ ├── process_kill_windows.go │ │ │ ├── process_spawn_windows.go │ │ │ ├── process_windows.go │ │ │ ├── root.go │ │ │ ├── version.go │ │ │ ├── wsl.go │ │ │ ├── wsl_info.go │ │ │ ├── wsl_integration_docker_linux.go │ │ │ ├── wsl_integration_linux.go │ │ │ └── wsl_integration_state_linux.go │ │ ├── go.mod │ │ ├── go.sum │ │ ├── main.go │ │ ├── pkg/ │ │ │ ├── certificates/ │ │ │ │ ├── certificates_windows.go │ │ │ │ └── certificates_windows_test.go │ │ │ ├── dockerproxy/ │ │ │ │ ├── defaults.go │ │ │ │ ├── generate.go │ │ │ │ ├── models/ │ │ │ │ │ └── doc.go │ │ │ │ ├── mungers/ │ │ │ │ │ ├── containers_create_linux.go │ │ │ │ │ ├── containers_create_linux_test.go │ │ │ │ │ ├── containers_create_windows.go │ │ │ │ │ ├── containers_create_windows_test.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── helpers.go │ │ │ │ │ └── helpers_linux.go │ │ │ │ ├── platform/ │ │ │ │ │ ├── hyperv.go │ │ │ │ │ ├── hyperv_test.go │ │ │ │ │ ├── serve_linux.go │ │ │ │ │ ├── serve_windows.go │ │ │ │ │ ├── serve_windows_test.go │ │ │ │ │ ├── vsock_linux.go │ │ │ │ │ └── wsl_mountpoint_linux.go │ │ │ │ ├── serve.go │ │ │ │ ├── start.go │ │ │ │ ├── swagger-configuration.yaml │ │ │ │ └── util/ │ │ │ │ ├── pipe.go │ │ │ │ ├── pipe_test.go │ │ │ │ ├── reverse_proxy.go │ │ │ │ └── reverse_proxy_test.go │ │ │ ├── integration/ │ │ │ │ ├── docker_linux.go │ │ │ │ ├── docker_linux_test.go │ │ │ │ └── integration.go │ │ │ ├── process/ │ │ │ │ ├── imports_windows.go │ │ │ │ ├── kill_others_linux.go │ │ │ │ ├── kill_windows.go │ │ │ │ ├── run_windows.go │ │ │ │ └── wait_windows.go │ │ │ ├── version/ │ │ │ │ └── version.go │ │ │ └── wsl-utils/ │ │ │ ├── doc.go │ │ │ ├── install_windows.go │ │ │ ├── run_windows.go │ │ │ ├── version_windows.go │ │ │ └── version_windows_test.go │ │ └── wix/ │ │ ├── check_windows.go │ │ ├── doc.go │ │ ├── helpers_windows.go │ │ ├── imports_windows.go │ │ ├── install_windows.go │ │ └── main_windows.go │ └── sudo-prompt/ │ ├── build-sudo-prompt │ ├── sudo-prompt-script │ └── sudo-prompt.applescript └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # EditorConfig helps developers define and maintain consistent # coding styles between different editors and IDEs # editorconfig.org root = true [*] charset = utf-8 end_of_line = lf indent_size = 2 indent_style = space insert_final_newline = true quote_type = single trim_trailing_whitespace = true [*.go] indent_style = tab [*.{sh,bash,bats}] indent_size = 4 simplify = true ================================================ FILE: .gitattributes ================================================ # All Linux scripts should have LF line endings # But only text files should be changed (not any binaries / images / etc.) resources/linux/** text=auto eol=lf resources/setup-spin text=auto eol=lf pkg/rancher-desktop/assets/scripts/** text=auto eol=lf ================================================ FILE: .github/.yamlfmt ================================================ formatter: indentless_arrays: true retain_line_breaks: true ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: Report Rancher Desktop issue labels: ["kind/bug"] body: - type: textarea attributes: label: Actual Behavior description: "A clear and concise description of what the bug is." validations: required: true - type: textarea attributes: label: Steps to Reproduce description: "Please, describe the steps to reproduce the behaviour." validations: required: true - type: textarea attributes: label: Result description: "Please, show what error or behaviour you're seeing." validations: required: true - type: textarea attributes: label: Expected Behavior description: "A clear and concise description of what you expected to happen." validations: required: true - type: textarea attributes: label: Additional Information description: >- Add any other context about the problem here. Please make sure to excerpt or attach logs. Include screenshots if appropriate, but they are not a replacement for logs. - type: input attributes: label: Rancher Desktop Version description: "What version of Rancher Desktop are you using?" placeholder: "e.g. 1.1.1" validations: required: true - type: input attributes: label: Rancher Desktop K8s Version description: "What version of Kubernetes are you using?" placeholder: "e.g. 1.99.9" validations: required: true - type: dropdown attributes: label: "Which container engine are you using?" options: - containerd (nerdctl) - moby (docker cli) validations: required: true - type: dropdown attributes: label: "What operating system are you using?" options: - macOS - Windows - Ubuntu - Other Linux - Other (specify below) validations: required: true - type: input attributes: label: Operating System / Build Version description: "What operating system and build version are you using?" placeholder: "e.g. Windows 10 Home 1909, macOS Monterey 12.0.1, Ubuntu 20.04, etc..." validations: required: true - type: dropdown attributes: label: What CPU architecture are you using? options: - x64 - ia32 - arm64 (Apple Silicon) validations: required: true - type: dropdown attributes: label: "Linux only: what package format did you use to install Rancher Desktop?" options: - N/A - deb - rpm - AppImage - Flatpak validations: required: false - type: textarea attributes: label: Windows User Only description: "Are you using VPN, Proxy, Special Firewall rules, Security Software or custom Activity directory features? if Yes, please describe." placeholder: "e.g. VPN PulseSecure, Kaspersky Total Security 21.2.x, custom proxy configs, activity directory features, or N/A" validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: Ask a question (GitHub Discussions) url: https://github.com/rancher-sandbox/rancher-desktop/discussions about: We use GitHub Discussions for questions and GitHub issues for tracking bug reports and feature requests - name: Chat with Rancher Desktop users and developers url: https://slack.rancher.io/ about: We hang out in the `#rancher-desktop` channel in the Rancher Users slack ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature Request description: "Suggest a feature or idea to Rancher Desktop." labels: ["kind/enhancement"] body: - type: textarea attributes: label: Problem Description description: "A clear and concise description of what enhancement you'd like." validations: required: true - type: textarea attributes: label: Proposed Solution description: "Describe the solution you'd like in a clear and concise manner." validations: required: true - type: textarea attributes: label: Additional Information description: "Add any other context/information about the problem here." validations: required: false ================================================ FILE: .github/actions/get-token/action.yaml ================================================ name: Get Token description: >- This action attempts to get a token with the requested permissions; if this is not running from the upstream repository, it attempts to get the token from a secret. Otherwise, it uses the vault actions. This requires permissions set described in https://github.com/rancher-eio/read-vault-secrets inputs: token-secret: description: Secret to fall back to required: false outputs: token: description: The GitHub token retrieved value: ${{ github.repository == 'rancher-sandbox/rancher-desktop' && steps.gen-token.outputs.token || steps.get-secret.outputs.token }} runs: using: composite steps: - id: vault name: Read vault secrets if: github.repository == 'rancher-sandbox/rancher-desktop' uses: rancher-eio/read-vault-secrets@main with: secrets: | secret/data/github/repo/${{ github.repository }}/github/app-credentials appId | APP_ID ; secret/data/github/repo/${{ github.repository }}/github/app-credentials privateKey | PRIVATE_KEY - id: gen-token name: Generate token if: github.repository == 'rancher-sandbox/rancher-desktop' uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ env.APP_ID }} private-key: ${{ env.PRIVATE_KEY }} - id: get-secret name: Fetch secret. if: github.repository != 'rancher-sandbox/rancher-desktop' run: echo "token=$SECRET" >> "$GITHUB_OUTPUT" shell: bash env: SECRET: ${{ inputs.token-secret }} ================================================ FILE: .github/actions/setup-environment/action.yaml ================================================ name: Setup Environment description: >- This is a composite action that is used to set up the runner for running Rancher Desktop. inputs: user: default: '' description: >- (Linux only) The user to use to set up `pass` runs: using: composite steps: - name: "Windows: Stop unwanted services" if: runner.os == 'Windows' shell: pwsh run: >- Get-Service -ErrorAction Continue -Name @('W3SVC', 'docker') | Stop-Service - name: "Windows: Update any pre-installed WSL" if: runner.os == 'Windows' shell: pwsh run: | # Sometimes this results in a HTTP 403 for some reason; in that case, we # need to retry. do { wsl --update } while ( -not $? ) # Setting the default version also lets WSL finish updating. wsl --set-default-version 2 - name: "Windows: Install yq" if: runner.os == 'Windows' shell: bash run: | set -o xtrace bindir="$HOME/bin" if [[ ! "$PATH" =~ "$bindir" ]]; then bindir=/usr/bin fi if ! command -v yq; then mkdir -p "$bindir" curl --location --output "$bindir/yq.exe" \ https://github.com/mikefarah/yq/releases/download/v4.43.1/yq_windows_amd64.exe chmod a+x "$bindir/yq.exe" fi - name: "Linux: Determine whether sudo is required" if: runner.os == 'Linux' shell: bash id: sudo run: | if [[ $(id --user) -eq 0 ]]; then echo "sudo=command" >> "$GITHUB_OUTPUT" # Fix for https://github.com/rocky-linux/sig-cloud-instance-images/issues/56 chmod u+r /etc/shadow else echo "sudo=sudo" >> "$GITHUB_OUTPUT" fi - name: "Linux: Enable KVM access" if: runner.os == 'Linux' shell: bash run: ${{ steps.sudo.outputs.sudo }} chmod a+rwx /dev/kvm - name: "Linux: Set unprivileged port start to 80" if: runner.os == 'Linux' shell: bash run: >- ${{ steps.sudo.outputs.sudo }} sh -c 'echo 80 > /proc/sys/net/ipv4/ip_unprivileged_port_start' - name: "Linux: Install required packages" if: runner.os == 'Linux' shell: bash run: | source /etc/os-release for id in $ID $ID_LIKE; do case $id in suse|opensuse) ${{ steps.sudo.outputs.sudo }} zypper --non-interactive install \ fuse gawk git GraphicsMagick gtk3-tools jq mozilla-nss \ noto-sans-fonts password-store sudo xvfb-run xauth which if [[ ${GITHUB_JOB:-unknown} =~ appimage ]]; then ${{ steps.sudo.outputs.sudo }} zypper --non-interactive install \ libasound2 openssh-clients fi exit 0;; rocky|rhel|centos|fedora) if [[ "$id" != "fedora" ]]; then ${{ steps.sudo.outputs.sudo }} dnf install --assumeyes \ "https://dl.fedoraproject.org/pub/epel/epel-release-latest-${VERSION_ID%%.*}.noarch.rpm" ${{ steps.sudo.outputs.sudo }} /usr/bin/crb enable # spellcheck-ignore-line fi ${{ steps.sudo.outputs.sudo }} dnf install --assumeyes \ at-spi2-atk cups-libs git GraphicsMagick gtk3 jq \ libva nss pass procps-ng sudo xorg-x11-server-Xvfb \ /usr/bin/script \ --setopt=excludepkgs=systemd-standalone-tmpfiles exit 0;; debian|ubuntu) ${{ steps.sudo.outputs.sudo }} apt-get update ${{ steps.sudo.outputs.sudo }} apt-get install --verbose-versions --yes \ curl jq pass sudo xvfb exit 0;; esac done printf "Could not find known distribution in [%s %s]\n" "$ID" "$ID_LIKE" >&2 exit 1 - name: "Linux: Set up passwordless sudo" if: runner.os == 'Linux' shell: bash run: | case "$TARGET_USER" in ""|root) exit 0;; esac echo "$TARGET_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$TARGET_USER env: TARGET_USER: ${{ inputs.user || 'root' }} - name: "Linux: Initialize pass" if: runner.os == 'Linux' shell: >- /usr/bin/sudo --user=${{ inputs.user || 'root' }} --login --set-home --non-interactive bash {0} run: | # Configure the agent to allow default passwords HOMEDIR="$(gpgconf --list-dirs homedir)" # spellcheck-ignore-line mkdir -p "${HOMEDIR}" chmod 0700 "${HOMEDIR}" echo "allow-preset-passphrase" >> "${HOMEDIR}/gpg-agent.conf" # Create a GPG key gpg --quick-generate-key --yes --batch --passphrase '' \ user@rancher-desktop.test default \ default never # Get info about the newly created key DATA="$(gpg --batch --with-colons --with-keygrip --list-secret-keys)" FINGERPRINT="$(awk -F: '/^fpr:/ { print $10 ; exit }' <<< "${DATA}")" # spellcheck-ignore-line GRIP="$(awk -F: '/^grp:/ { print $10 ; exit }' <<< "${DATA}")" # Save the password gpg-connect-agent --verbose "PRESET_PASSPHRASE ${GRIP} -1 00" /bye # Initialize pass pass init "${FINGERPRINT}" ================================================ FILE: .github/actions/spelling/README.md ================================================ # check-spelling/check-spelling configuration File | Purpose | Format | Info ---- | ------- | ------ | ---- [dictionary.txt](dictionary.txt) | Replacement dictionary (creating this file will override the default dictionary) | one word per line | [dictionary](https://github.com/check-spelling/check-spelling/wiki/Configuration#dictionary) [allow.txt](allow.txt) | Add words to the dictionary | one word per line (only letters and `'`s allowed) | [allow](https://github.com/check-spelling/check-spelling/wiki/Configuration#allow) [reject.txt](reject.txt) | Remove words from the dictionary (after allow) | grep pattern matching whole dictionary words | [reject](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-reject) [excludes.txt](excludes.txt) | Files to ignore entirely | perl regular expression | [excludes](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-excludes) [only.txt](only.txt) | Only check matching files (applied after excludes) | perl regular expression | [only](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-only) [patterns.txt](patterns.txt) | Patterns to ignore from checked lines | perl regular expression (order matters, first match wins) | [patterns](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-patterns) [candidate.patterns](candidate.patterns) | Patterns that might be worth adding to [patterns.txt](patterns.txt) | perl regular expression with optional comment block introductions (all matches will be suggested) | [candidates](https://github.com/check-spelling/check-spelling/wiki/Feature:-Suggest-patterns) [line_forbidden.patterns](line_forbidden.patterns) | Patterns to flag in checked lines | perl regular expression (order matters, first match wins) | [patterns](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-patterns) [expect.txt](expect.txt) | Expected words that aren't in the dictionary | one word per line (sorted, alphabetically) | [expect](https://github.com/check-spelling/check-spelling/wiki/Configuration#expect) [advice.md](advice.md) | Supplement for GitHub comment when unrecognized words are found | GitHub Markdown | [advice](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-advice) Note: you can replace any of these files with a directory by the same name (minus the suffix) and then include multiple files inside that directory (with that suffix) to merge multiple files together. ================================================ FILE: .github/actions/spelling/advice.md ================================================
If the flagged items are :exploding_head: false positives If items relate to a ... * binary file (or some other file you wouldn't want to check at all). Please add a file path to the `excludes.txt` file matching the containing file. File paths are Perl 5 Regular Expressions - you can [test]( https://www.regexplanet.com/advanced/perl/) yours before committing to verify it will match your files. `^` refers to the file's path from the root of the repository, so `^README\.md$` would exclude [README.md]( ../tree/HEAD/README.md) (on whichever branch you're using). * well-formed pattern. If you can write a [pattern]( https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns ) that would match it, try adding it to the `patterns.txt` file. Patterns are Perl 5 Regular Expressions - you can [test]( https://www.regexplanet.com/advanced/perl/) yours before committing to verify it will match your lines. Note that patterns can't match multiline strings.
:steam_locomotive: If you're seeing this message and your PR is from a branch that doesn't have check-spelling, please merge to your PR's base branch to get the version configured for your repository. ================================================ FILE: .github/actions/spelling/allow.txt ================================================ emoji github https passwordless ssh ubuntu workarounds ================================================ FILE: .github/actions/spelling/candidate.patterns ================================================ # Repeated letters \b([A-Za-z])\g{-1}{2,}\b # marker to ignore all code on line ^.*/\* #no-spell-check-line \*/.*$ # marker to ignore all code on line ^.*\bno-spell-check(?:-line|)(?:\s.*|)$ # https://cspell.org/configuration/document-settings/ # cspell inline ^.*\b[Cc][Ss][Pp][Ee][Ll]{2}:\s*[Dd][Ii][Ss][Aa][Bb][Ll][Ee]-[Ll][Ii][Nn][Ee]\b # copyright Copyright (?:\([Cc]\)|)(?:[-\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+ # patch hunk comments ^@@ -\d+(?:,\d+|) \+\d+(?:,\d+|) @@ .* # git index header index (?:[0-9a-z]{7,40},|)[0-9a-z]{7,40}\.\.[0-9a-z]{7,40} # file permissions ['"`\s](?!-+\s)[-bcdLlpsw](?:[-r][-w][-Ssx]){2}[-r][-w][-SsTtx]\+?['"`\s] # css fonts \bfont(?:-family(?:[-\w+]*)|):[^;}]+ # css url wrappings \burl\([^)]+\) # cid urls (['"])cid:.*?\g{-1} # data url in parens \(data:(?:[^) ][^)]*?|)(?:[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})[^)]*\) # data url in quotes ([`'"])data:(?:[^ `'"].*?|)(?:[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,}).*\g{-1} # data url \bdata:[-a-zA-Z=;:/0-9+_]*,\S* # https/http/file urls (?:\b(?:https?|ftp|file)://)[-A-Za-z0-9+&@#/*%?=~_|!:,.;]+[-A-Za-z0-9+&@#/*%=~_|] # mailto urls mailto:[-a-zA-Z=;:/?%&0-9+@._]{3,} # magnet urls magnet:[?=:\w]+ # magnet urls "magnet:[^"]+" # obs: "obs:[^"]*" # The `\b` here means a break, it's the fancy way to handle urls, but it makes things harder to read # In this examples content, I'm using a number of different ways to match things to show various approaches # asciinema \basciinema\.org/a/[0-9a-zA-Z]+ # asciinema v2 ^\[\d+\.\d+, "[io]", ".*"\]$ # apple \bdeveloper\.apple\.com/[-\w?=/]+ # Apple music \bembed\.music\.apple\.com/fr/playlist/usr-share/[-\w.]+ # appveyor api \bci\.appveyor\.com/api/projects/status/[0-9a-z]+ # appveyor project \bci\.appveyor\.com/project/(?:[^/\s"]*/){2}builds?/\d+/job/[0-9a-z]+ # Amazon # Amazon \bamazon\.com/[-\w]+/(?:dp/[0-9A-Z]+|) # AWS ARN arn:aws:[-/:\w]+ # AWS S3 \b\w*\.s3[^.]*\.amazonaws\.com/[-\w/&#%_?:=]* # AWS execute-api \b[0-9a-z]{10}\.execute-api\.[-0-9a-z]+\.amazonaws\.com\b # AWS ELB \b\w+\.[-0-9a-z]+\.elb\.amazonaws\.com\b # AWS SNS \bsns\.[-0-9a-z]+.amazonaws\.com/[-\w/&#%_?:=]* # AWS VPC vpc-\w+ # While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there # YouTube url \b(?:(?:www\.|)youtube\.com|youtu.be)/(?:channel/|embed/|user/|playlist\?list=|watch\?v=|v/|)[-a-zA-Z0-9?&=_%]* # YouTube music \bmusic\.youtube\.com/youtubei/v1/browse(?:[?&]\w+=[-a-zA-Z0-9?&=_]*) # YouTube tag <\s*youtube\s+id=['"][-a-zA-Z0-9?_]*['"] # YouTube image \bimg\.youtube\.com/vi/[-a-zA-Z0-9?&=_]* # Google Accounts \baccounts.google.com/[-_/?=.:;+%&0-9a-zA-Z]* # Google Analytics \bgoogle-analytics\.com/collect.[-0-9a-zA-Z?%=&_.~]* # Google APIs \bgoogleapis\.(?:com|dev)/[a-z]+/(?:v\d+/|)[a-z]+/[-@:./?=\w+|&]+ # Google Artifact Registry \.pkg\.dev(?:/[-\w]+)+(?::[-\w]+|) # Google Storage \b[-a-zA-Z0-9.]*\bstorage\d*\.googleapis\.com(?:/\S*|) # Google Calendar \bcalendar\.google\.com/calendar(?:/u/\d+|)/embed\?src=[@./?=\w&%]+ \w+\@group\.calendar\.google\.com\b # Google DataStudio \bdatastudio\.google\.com/(?:(?:c/|)u/\d+/|)(?:embed/|)(?:open|reporting|datasources|s)/[-0-9a-zA-Z]+(?:/page/[-0-9a-zA-Z]+|) # The leading `/` here is as opposed to the `\b` above # ... a short way to match `https://` or `http://` since most urls have one of those prefixes # Google Docs /docs\.google\.com/[a-z]+/(?:ccc\?key=\w+|(?:u/\d+|d/(?:e/|)[0-9a-zA-Z_-]+/)?(?:edit\?[-\w=#.]*|/\?[\w=&]*|)) # Google Drive \bdrive\.google\.com/(?:file/d/|open)[-0-9a-zA-Z_?=]* # Google Groups \bgroups\.google\.com(?:/[a-z]+/(?:#!|)[^/\s"]+)* # Google Maps \bmaps\.google\.com/maps\?[\w&;=]* # Google themes themes\.googleusercontent\.com/static/fonts/[^/\s"]+/v\d+/[^.]+. # Google CDN \bclients2\.google(?:usercontent|)\.com[-0-9a-zA-Z/.]* # Goo.gl /goo\.gl/[a-zA-Z0-9]+ # Google Chrome Store \bchrome\.google\.com/webstore/detail/[-\w]*(?:/\w*|) # Google Books \bgoogle\.(?:\w{2,4})/books(?:/\w+)*\?[-\w\d=&#.]* # Google Fonts \bfonts\.(?:googleapis|gstatic)\.com/[-/?=:;+&0-9a-zA-Z]* # Google Forms \bforms\.gle/\w+ # Google Scholar \bscholar\.google\.com/citations\?user=[A-Za-z0-9_]+ # Google Colab Research Drive \bcolab\.research\.google\.com/drive/[-0-9a-zA-Z_?=]* # Google Cloud regions (?:us|(?:north|south)america|europe|asia|australia|me|africa)-(?:north|south|east|west|central){1,2}\d+ # GitHub SHAs (api) \bapi.github\.com/repos(?:/[^/\s"]+){3}/[0-9a-f]+\b # GitHub SHAs (markdown) (?:\[`?[0-9a-f]+`?\]\(https:/|)/(?:www\.|)github\.com(?:/[^/\s"]+){2,}(?:/[^/\s")]+)(?:[0-9a-f]+(?:[-0-9a-zA-Z/#.]*|)\b|) # GitHub SHAs \bgithub\.com(?:/[^/\s"]+){2}[@#][0-9a-f]+\b # GitHub SHA refs \[([0-9a-f]+)\]\(https://(?:www\.|)github.com/[-\w]+/[-\w]+/commit/\g{-1}[0-9a-f]* # GitHub wiki \bgithub\.com/(?:[^/]+/){2}wiki/(?:(?:[^/]+/|)_history|[^/]+(?:/_compare|)/[0-9a-f.]{40,})\b # githubusercontent /[-a-z0-9]+\.githubusercontent\.com/[-a-zA-Z0-9?&=_\/.]* # githubassets \bgithubassets.com/[0-9a-f]+(?:[-/\w.]+) # gist github \bgist\.github\.com/[^/\s"]+/[0-9a-f]+ # git.io \bgit\.io/[0-9a-zA-Z]+ # GitHub JSON "node_id": "[-a-zA-Z=;:/0-9+_]*" # Contributor \[[^\]]+\]\(https://github\.com/[^/\s"]+/?\) # GHSA GHSA(?:-[0-9a-z]{4}){3} # GitHub actions \buses:\s+(['"]?)[-\w.]+/[-\w./]+@[-\w.]+\g{-1} # GitLab commit \bgitlab\.[^/\s"]*/\S+/\S+/commit/[0-9a-f]{7,16}#[0-9a-f]{40}\b # GitLab merge requests \bgitlab\.[^/\s"]*/\S+/\S+/-/merge_requests/\d+/diffs#[0-9a-f]{40}\b # GitLab uploads \bgitlab\.[^/\s"]*/uploads/[-a-zA-Z=;:/0-9+]* # GitLab commits \bgitlab\.[^/\s"]*/(?:[^/\s"]+/){2}commits?/[0-9a-f]+\b # #includes ^\s*#include\s*(?:<.*?>|".*?") # #pragma lib ^\s*#pragma comment\(lib, ".*?"\) # binance accounts\.binance\.com/[a-z/]*oauth/authorize\?[-0-9a-zA-Z&%]* # bitbucket diff \bapi\.bitbucket\.org/\d+\.\d+/repositories/(?:[^/\s"]+/){2}diff(?:stat|)(?:/[^/\s"]+){2}:[0-9a-f]+ # bitbucket repositories commits \bapi\.bitbucket\.org/\d+\.\d+/repositories/(?:[^/\s"]+/){2}commits?/[0-9a-f]+ # bitbucket commits \bbitbucket\.org/(?:[^/\s"]+/){2}commits?/[0-9a-f]+ # bit.ly \bbit\.ly/\w+ # bitrise \bapp\.bitrise\.io/app/[0-9a-f]*/[\w.?=&]* # bootstrapcdn.com \bbootstrapcdn\.com/[-./\w]+ # cdn.cloudflare.com \bcdnjs\.cloudflare\.com/[./\w]+ # circleci \bcircleci\.com/gh(?:/[^/\s"]+){1,5}.[a-z]+\?[-0-9a-zA-Z=&]+ # gitter \bgitter\.im(?:/[^/\s"]+){2}\?at=[0-9a-f]+ # gravatar \bgravatar\.com/avatar/[0-9a-f]+ # ibm [a-z.]*ibm\.com/[-_#=:%!?~.\\/\d\w]* # imgur \bimgur\.com/[^.]+ # Internet Archive \barchive\.org/web/\d+/(?:[-\w.?,'/\\+&%$#_:]*) # discord /discord(?:app\.com|\.gg)/(?:invite/)?[a-zA-Z0-9]{7,} # Disqus \bdisqus\.com/[-\w/%.()!?&=_]* # medium link \blink\.medium\.com/[a-zA-Z0-9]+ # medium \bmedium\.com/@?[^/\s"]+/[-\w]+ # microsoft \b(?:https?://|)(?:(?:(?:blogs|download\.visualstudio|docs|msdn2?|research)\.|)microsoft|blogs\.msdn)\.co(?:m|\.\w\w)/[-_a-zA-Z0-9()=./%]* # powerbi \bapp\.powerbi\.com/reportEmbed/[^"' ]* # vs devops \bvisualstudio.com(?::443|)/[-\w/?=%&.]* # microsoft store \bmicrosoft\.com/store/apps/\w+ # mvnrepository.com \bmvnrepository\.com/[-0-9a-z./]+ # now.sh /[0-9a-z-.]+\.now\.sh\b # oracle \bdocs\.oracle\.com/[-0-9a-zA-Z./_?#&=]* # chromatic.com /\S+.chromatic.com\S*[")] # codacy \bapi\.codacy\.com/project/badge/Grade/[0-9a-f]+ # compai \bcompai\.pub/v1/png/[0-9a-f]+ # mailgun api \.api\.mailgun\.net/v3/domains/[0-9a-z]+\.mailgun.org/messages/[0-9a-zA-Z=@]* # mailgun \b[0-9a-z]+.mailgun.org # /message-id/ /message-id/[-\w@./%]+ # Reddit \breddit\.com/r/[/\w_]* # requestb.in \brequestb\.in/[0-9a-z]+ # sched \b[a-z0-9]+\.sched\.com\b # Slack url slack://[a-zA-Z0-9?&=]+ # Slack \bslack\.com/[-0-9a-zA-Z/_~?&=.]* # Slack edge \bslack-edge\.com/[-a-zA-Z0-9?&=%./]+ # Slack images \bslack-imgs\.com/[-a-zA-Z0-9?&=%.]+ # shields.io \bshields\.io/[-\w/%?=&.:+;,]* # stackexchange -- https://stackexchange.com/feeds/sites \b(?:askubuntu|serverfault|stack(?:exchange|overflow)|superuser).com/(?:questions/\w+/[-\w]+|a/) # Sentry [0-9a-f]{32}\@o\d+\.ingest\.sentry\.io\b # Twitter markdown \[@[^[/\]:]*?\]\(https://twitter.com/[^/\s"')]*(?:/status/\d+(?:\?[-_0-9a-zA-Z&=]*|)|)\) # Twitter hashtag \btwitter\.com/hashtag/[\w?_=&]* # Twitter status \btwitter\.com/[^/\s"')]*(?:/status/\d+(?:\?[-_0-9a-zA-Z&=]*|)|) # Twitter profile images \btwimg\.com/profile_images/[_\w./]* # Twitter media \btwimg\.com/media/[-_\w./?=]* # Twitter link shortened \bt\.co/\w+ # facebook \bfburl\.com/[0-9a-z_]+ # facebook CDN \bfbcdn\.net/[\w/.,]* # facebook watch \bfb\.watch/[0-9A-Za-z]+ # dropbox \bdropbox\.com/sh?/[^/\s"]+/[-0-9A-Za-z_.%?=&;]+ # ipfs protocol ipfs://[0-9a-zA-Z]{3,} # ipfs url /ipfs/[0-9a-zA-Z]{3,} # w3 \bw3\.org/[-0-9a-zA-Z/#.]+ # loom \bloom\.com/embed/[0-9a-f]+ # regex101 \bregex101\.com/r/[^/\s"]+/\d+ # figma \bfigma\.com/file(?:/[0-9a-zA-Z]+/)+ # freecodecamp.org \bfreecodecamp\.org/[-\w/.]+ # image.tmdb.org \bimage\.tmdb\.org/[/\w.]+ # mermaid \bmermaid\.ink/img/[-\w]+|\bmermaid-js\.github\.io/mermaid-live-editor/#/edit/[-\w]+ # Wikipedia \ben\.wikipedia\.org/wiki/[-\w%.#]+ # gitweb [^"\s]+/gitweb/\S+;h=[0-9a-f]+ # HyperKitty lists /archives/list/[^@/]+@[^/\s"]*/message/[^/\s"]*/ # lists /thread\.html/[^"\s]+ # list-management \blist-manage\.com/subscribe(?:[?&](?:u|id)=[0-9a-f]+)+ # kubectl.kubernetes.io/last-applied-configuration "kubectl.kubernetes.io/last-applied-configuration": ".*" # pgp \bgnupg\.net/pks/lookup[?&=0-9a-zA-Z]* # Spotify \bopen\.spotify\.com/embed/playlist/\w+ # Mastodon \bmastodon\.[-a-z.]*/(?:media/|@)[?&=0-9a-zA-Z_]* # scastie \bscastie\.scala-lang\.org/[^/]+/\w+ # images.unsplash.com \bimages\.unsplash\.com/(?:(?:flagged|reserve)/|)[-\w./%?=%&.;]+ # pastebin \bpastebin\.com/[\w/]+ # heroku \b\w+\.heroku\.com/source/archive/\w+ # quip \b\w+\.quip\.com/\w+(?:(?:#|/issues/)\w+)? # badgen.net \bbadgen\.net/badge/[^")\]'\s]+ # statuspage.io \w+\.statuspage\.io\b # media.giphy.com \bmedia\.giphy\.com/media/[^/]+/[\w.?&=]+ # tinyurl \btinyurl\.com/\w+ # codepen \bcodepen\.io/[\w/]+ # registry.npmjs.org \bregistry\.npmjs\.org/(?:@[^/"']+/|)[^/"']+/-/[-\w@.]+ # getopts \bgetopts\s+(?:"[^"]+"|'[^']+') # ANSI color codes (?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+)*m # URL escaped characters %[0-9A-F][A-F](?=[A-Za-z]) # lower URL escaped characters #%[0-9a-f][a-f](?=[a-z]{2,}) # IPv6 \b(?:[0-9a-fA-F]{0,4}:){3,7}[0-9a-fA-F]{0,4}\b # c99 hex digits (not the full format, just one I've seen) 0x[0-9a-fA-F](?:\.[0-9a-fA-F]*|)[pP] # Punycode \bxn--[-0-9a-z]+ # sha sha\d+:[0-9a-f]*?[a-f]{3,}[0-9a-f]* # sha-... -- uses a fancy capture (\\?['"]|")[0-9a-f]{40,}\g{-1} # hex runs \b(?=(?:[a-fA-F]{0,2}\d)*[a-fA-F]{3})[0-9a-fA-F]{16,}\b # hex in url queries =[0-9a-fA-F]*?(?:[A-F]{3,}|[a-f]{3,})[0-9a-fA-F]*?& # ssh (?:ssh-\S+|-nistp256) [-a-zA-Z=;:/0-9+]{12,} # PGP \b(?:[0-9A-F]{4} ){9}[0-9A-F]{4}\b # GPG keys \b(?:[0-9A-F]{4} ){5}(?: [0-9A-F]{4}){5}\b # Well known gpg keys .well-known/openpgpkey/[\w./]+ # pki -----BEGIN.*-----END # pki (base64) LS0tLS1CRUdJT.* # C# includes ^\s*using [^;]+; # uuid: \b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b # hex digits including css/html color classes: (?:[\\0][xX]|\\u\{?|[uU]\+|#x?|%23|&H)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|[iu]\d+)\b # integrity integrity=(['"])(?:\s*sha\d+-[-a-zA-Z=;:/0-9+]{40,})+\g{-1} # https://www.gnu.org/software/groff/manual/groff.html # man troff content \\f[BCIPR] # '/" \\\([ad]q # .desktop mime types ^MimeTypes?=.*$ # .desktop localized entries ^[A-Z][a-z]+\[[a-z]+\]=.*$ # Localized .desktop content Name\[[^\]]+\]=.* # IServiceProvider / isAThing #(?:(?:\b|_|(?<=[a-z]))I|(?:\b|_)(?:nsI|isA))(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\d]|\b)) # python \b(?i)py(?!gments|gmy|lon|ramid|ro|th)(?=[a-z]{2,}) # crypt (['"])\$2[ayb]\$.{56}\g{-1} # apache/old crypt (['"]|)\$+(?:apr|)1\$+.{8}\$+.{22}\g{-1} # sha1 hash \{SHA\}[-a-zA-Z=;:/0-9+]{3,} # machine learning (?) \b(?i)ml(?=[a-z]{2,}) # scrypt / argon \$(?:scrypt|argon\d+[di]*)\$\S+ # go.sum \bh1:\S+ # golang print-f-style functions (?i)(?<=append|comma|debug|equal|err|error|exit|fatal|format|info|log|name|panic|print|skip|scan|string|trace|true|warn|warning|wrap|write)(?:f|ln)[ (] # golang regular expression (?|m([|!/@#,;']).*?\g{-1}) # perl qr regex (?|\(.*?\)|([|!/@#,;']).*?\g{-1}) # perl run perl(?:\s+-[a-zA-Z]\w*)+ # C network byte conversions (?:\d|\bh)to(?!ken)(?=[a-z])|to(?=[adhiklpun]\() # Go regular expressions regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\) # regex choice \((?:\?:|)[^)|]+(?.*?< # jetbrains schema https://youtrack.jetbrains.com/issue/RSRP-489571 urn:shemas-jetbrains-com # Debian changelog severity [-\w]+ \(.*\) (?:\w+|baseline|unstable|experimental); urgency=(?:low|medium|high|emergency|critical)\b # kubernetes pod status lists # https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase \w+(?:-\w+)+\s+\d+/\d+\s+(?:Running|Pending|Succeeded|Failed|Unknown)\s+ # kubectl - pods in CrashLoopBackOff \w+-[0-9a-f]+-\w+\s+\d+/\d+\s+CrashLoopBackOff\s+ # kubernetes applications \.apps/[-\w]+ # kubernetes object suffix -[0-9a-f]{10}-\w{5}\s # kubernetes crd patterns ^\s*pattern: .*$ # posthog secrets ([`'"])phc_[^"',]+\g{-1} # xcode # xcodeproject scenes (?:Controller|destination|(?:first|second)Item|ID|id)="\w{3}-\w{2}-\w{3}" # xcode api botches customObjectInstantitationMethod # msvc api botches PrependWithABINamepsace # configure flags .* \| --\w{2,}.*?(?=\w+\s\w+) # font awesome classes \.fa-[-a-z0-9]+ # bearer auth (['"])[Bb]ear[e][r] .{3,}?\g{-1} # bearer auth \b[Bb]ear[e][r]:? [-a-zA-Z=;:/0-9+.]{3,} # basic auth (['"])[Bb]asic [-a-zA-Z=;:/0-9+]{3,}\g{-1} # basic auth : [Bb]asic [-a-zA-Z=;:/0-9+.]{3,} # base64 encoded content #([`'"])[-a-zA-Z=;:/0-9+]{3,}=\g{-1} # base64 encoded content in xml/sgml >[-a-zA-Z=;:/0-9+]{3,}=?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_]{40,} # DNS rr data (?:\d+\s+){3}(?:[-+/=.\w]{2,}\s*){1,2} # encoded-word =\?[-a-zA-Z0-9"*%]+\?[BQ]\?[^?]{0,75}\?= # numerator \bnumer\b(?=.*denom) # Time Zones \b(?:Africa|Atlantic|America|Antarctica|Arctic|Asia|Australia|Europe|Indian|Pacific)(?:/[-\w]+)+ # linux kernel info ^(?:bugs|flags|Features)\s+:.* # systemd mode systemd.*?running in system mode \([-+].*\)$ # Lorem # Update Lorem based on your content (requires `ge` and `w` from https://github.com/jsoref/spelling; and `review` from https://github.com/check-spelling/check-spelling/wiki/Looking-for-items-locally ) # grep '^[^#].*lorem' .github/actions/spelling/patterns.txt|perl -pne 's/.*i..\?://;s/\).*//' |tr '|' "\n"|sort -f |xargs -n1 ge|perl -pne 's/^[^:]*://'|sort -u|w|sed -e 's/ .*//'|w|review - # Warning, while `(?i)` is very neat and fancy, if you have some binary files that aren't proper unicode, you might run into: # ... Operation "substitution (s///)" returns its argument for non-Unicode code point 0x1C19AE (the code point will vary). # ... You could manually change `(?i)X...` to use `[Xx]...` # ... or you could add the files to your `excludes` file (a version after 0.0.19 should identify the file path) (?:(?:\w|\s|[,.])*\b(?i)(?:amet|consectetur|cursus|dolor|eros|ipsum|lacus|libero|ligula|lorem|magna|neque|nulla|suscipit|tempus)\b(?:\w|\s|[,.])*) # Non-English # Even repositories expecting pure English content can unintentionally have Non-English content... People will occasionally mistakenly enter [homoglyphs](https://en.wikipedia.org/wiki/Homoglyph) which are essentially typos, and using this pattern will mean check-spelling will not complain about them. # . # If the content to be checked should be written in English and the only Non-English items will be people's names, then you can consider adding this. # . # Alternatively, if you're using check-spelling v0.0.25+, and you would like to _check_ the Non-English content for spelling errors, you can. For information on how to do so, see: # https://docs.check-spelling.dev/Feature:-Configurable-word-characters.html#unicode [a-zA-Z]*[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3}[a-zA-ZÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]*|[a-zA-Z]{3,}[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]|[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3,} # highlighted letters \[[A-Z]\][a-z]+ # French # This corpus only had capital letters, but you probably want lowercase ones as well. \b[LN]'+[a-z]{2,}\b # latex (check-spelling >= 0.0.22) \\\w{2,}\{ # American Mathematical Society (AMS) / Doxygen TeX/AMS # File extensions \*\.[+\w]+, # eslint "varsIgnorePattern": ".+" # nolint nolint:\s*[\w,]+ # Windows short paths [/\\][^/\\]{5,6}~\d{1,2}(?=[/\\]) # Windows Resources with accelerators \b[A-Z]&[a-z]+\b(?!;) # signed off by (?i)Signed-off-by: .* # cygwin paths /cygdrive/[a-zA-Z]/(?:Program Files(?: \(.*?\)| ?)(?:/[-+.~\\/()\w ]+)*|[-+.~\\/()\w])+ # in check-spelling@v0.0.22+, printf markers aren't automatically consumed # printf markers #(?v# (?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_])) # Compiler flags (Unix, Java/Scala) # Use if you have things like `-Pdocker` and want to treat them as `docker` #(?:^|[\t ,>"'`=(#])-(?:(?:J-|)[DPWXY]|[Llf])(?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,}) # Compiler flags (Windows / PowerShell) # This is a subset of the more general compiler flags pattern. # It avoids matching `-Path` to prevent it from being treated as `ath` #(?:^|[\t ,"'`=(#])-(?:[DPL](?=[A-Z]{2,})|[WXYlf](?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,})) # Compiler flags (linker) ,-B # Library prefix # e.g., `lib`+`archive`, `lib`+`raw`, `lib`+`unwind` # (ignores some words that happen to start with `lib`) (?:\b|_)[Ll]ib(?!era[lt])(?:re(?=office)|era|)(?!ero|erty|rar(?:i(?:an|es)|y))(?=[a-z]) # iSCSI iqn (approximate regex) \biqn\.[0-9]{4}-[0-9]{2}(?:[\.-][a-z][a-z0-9]*)*\b # WWNN/WWPN (NAA identifiers) \b(?:0x)?10[0-9a-f]{14}\b|\b(?:0x|3)?[25][0-9a-f]{15}\b|\b(?:0x|3)?6[0-9a-f]{31}\b # curl arguments \b(?:\\n|)curl(?:\.exe|)(?:\s+-[a-zA-Z]{1,2}\b)*(?:\s+-[a-zA-Z]{3,})(?:\s+-[a-zA-Z]+)* # set arguments \b(?:bash|sh|set)(?:\s+[-+][abefimouxE]{1,2})*\s+[-+][abefimouxE]{3,}(?:\s+[-+][abefimouxE]+)* # tar arguments \b(?:\\n|)g?tar(?:\.exe|)(?:\s-C \S+|(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+ # tput arguments -- https://man7.org/linux/man-pages/man5/terminfo.5.html -- technically they can be more than 5 chars long... \btput\s+(?:(?:-[SV]|-T\s*\w+)\s+)*\w{3,5}\b # macOS temp folders /var/folders/\w\w/[+\w]+/(?:T|-Caches-)/ # github runner temp folders /home/runner/work/_temp/[-_/a-z0-9]+ ================================================ FILE: .github/actions/spelling/excludes.txt ================================================ # See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-excludes (?:^|/)(?i)COPYRIGHT (?:^|/)(?i)LICEN[CS]E (?:^|/)(?i)third[-_]?party/ (?:^|/)3rdparty/ (?:^|/)generated/ (?:^|/)go\.(?:work\.)?sum$ (?:^|/)package(?:-lock|)\.json$ (?:^|/)Pipfile$ (?:^|/)pyproject.toml (?:^|/)vendor/ (?:^|/|\b)requirements(?:-dev|-doc|-test|)\.txt$ -lock\.yaml$ \.a$ \.ai$ \.all-contributorsrc$ \.avi$ \.bmp$ \.bz2$ \.cert?$|\.crt$ \.class$ \.coveragerc$ \.crl$ \.csr$ \.dll$ \.docx?$ \.drawio$ \.DS_Store$ \.eot$ \.eps$ \.exe$ \.gif$ \.git-blame-ignore-revs$ \.gitattributes$ \.gitkeep$ \.graffle$ \.gz$ \.icns$ \.ico$ \.ipynb$ \.jar$ \.jks$ \.jpe?g$ \.key$ \.kiwi$ \.lib$ \.lock$ \.map$ \.min\.. \.mo$ \.mod$ \.mp[34]$ \.o$ \.ocf$ \.otf$ \.p12$ \.parquet$ \.pdf$ \.pem$ \.pfx$ \.png$ \.psd$ \.pyc$ \.pylintrc$ \.qm$ \.s$ \.sig$ \.so$ \.svgz?$ \.sys$ \.tar$ \.tgz$ \.tiff?$ \.ttf$ \.wav$ \.webm$ \.webp$ \.woff2?$ \.xcf$ \.xlsx?$ \.xpm$ \.xz$ \.zip$ ^\.github/actions/spelling/ ^\Q.github/workflows/spelling.yml\E$ ^\Qpkg/rancher-desktop/assets/styles/fonts/_dots.scss\E$ ^\Qpkg/rancher-desktop/assets/styles/fonts/_zerowidthspace.scss\E$ ^\Qpkg/rancher-desktop/utils/processOutputInterpreters/__tests__/assets/build.txt\E$ ^\Qpkg/rancher-desktop/utils/processOutputInterpreters/__tests__/assets/pull03.txt\E$ ^\Qpkg/rancher-desktop/utils/processOutputInterpreters/__tests__/assets/push.txt\E$ ^\QSECURITY.md\E$ ^pkg/rancher-desktop/assets/scripts/logrotate-k3s$ ^pkg/rancher-desktop/assets/scripts/logrotate-lima-guestagent$ ^pkg/rancher-desktop/sudo-prompt/ ^pkg/rancher-desktop/utils/processOutputInterpreters/__tests__/assets/trivy-image-postgres ignore$ /translations/(?!en) ^\Qpkg/rancher-desktop/router.js\E$ ^\Qsrc/go/nerdctl-stub/nerdctl_commands_generated.go\E$ ^\Q.golangci.yaml\E$ ^\Qsrc/go/networking/.golangci.yml\E$ # Generated file ^\Qpkg/rancher-desktop/assets/extension-data.yaml\E$ # Mostly image names ^\Qscripts/assets/extension-data.yaml\E$ # Test data ^screenshots/test-data/ ================================================ FILE: .github/actions/spelling/expect.txt ================================================ abbrv actionmenu ACTIONSTART activedirectory addexclusion addext addgroup addlabel addrepo adfs adrg airgap aks alertmanager alibaba aliyun aliyunecs aliyunkubernetescontainerservice allcols allusers ALLPLATFORMS altgraph andsection apierrors apify apiservice apitracker APPDIR appimage appimagekit APPLEID applescript APPLICATIONFOLDER ARPNOMODIFY ARPPRODUCTICON ARPURLINFOABOUT arrowdown arrowleft arrowright arrowup asound assumeyes atk authconfig authdata Authenticode authprovider auxww Awop backgrounding backuptip baiducloudcontainerengine bannrbmp banzaicloud basedisk bassano batslib bci bellingham binfmt blahblah blockmap Blt bootfs bosco bpf bpffs browserhome brucebean bsdtar buildctl buildkit buildkitd buildmode buildroot bulbasaur bulkable bulkaction cacerts camelpunch CAPI capslock caroot catalogtemplate cbr CCE ceci cecinestpasuncategory ceph certutil cgroupfs checkpath chirico cidata cidfile Cim clientcmd clientset clonefile cloudca CNCF cni cnutils commitish composefile confd configjson configmap conflist constrainttemplate containerapi containerd CONTAINERENGINE containernetworking cooldown copypac coredns cpanm cpanminus crds credfwd CREDHELPER cri crond cshrc ctrctl ctxs daemonset dapp datadog dcmonitor dcnone dcrd debbuild Debugw decapsulates dedot deepmap defattr deislabs destinationrule DETECTEXCEPTIONS developercertificate DEVMODE dfile didinit diffdisk digitalocean dirents distatus distros Dlg dlgbmp dlicense DNAT dnsmasq doclink donotuse dport dri Duex dustin Dwm dwmapi DWMWA EACCESS eagerzeroedthick eastus ebegin ecm edmonton einfo electronjs elko endgroup engineimage epinio EPONY errdefs ERRFILE errgroup Errorw errwrap escapehtml estargz ESXi etcdbackup ETest euid eula excludepkgs excludesection Executability EXITDIALOGOPTIONALCHECKBOX EXITDIALOGOPTIONALCHECKBOXTEXT exoscale externalname externalservice extglob factoryreset fakercfile fanotify fav fdx featurename FEEEFEEE femto ffi Fflags ficlone filekey filestat fineprint fleetworkspace FOLDERID fontstack fqname freeipa frontends fscache gabcdef gazornaanplatt gcs GENERALIZEDTIME getwindowid gha gitmodules gitrepo gke globalrole globalrolebinding Gluster gname goland gomod googlegke googleoauth gopacket GOTOOLCHAIN GOWORK govet gpu gtk guestagent Gutterless gvisor hardlinks hashicorp HDD healthz Hec heketi helmcharts hfs hkcu hklm HMR hocs horizontalpodautoscaler HOSTPORT hoverable howett hpa HRESULT htpasswd httpconfig Huawei huaweicce hvsock hwnd hyperv icns Idempotently identitytoken iex ifaces ifname ifnotstart ifstarted iidfile imageinfo IMAGENAME IMAGEPATH indentless installable INSTALLMESSAGE INSTALLPROPERTY IPlugin iptable Isf islabel isthebestmeshuggahalbum istio istiod isv itp iwr jetstack JOBOBJECT joycelin jsmith JSONOr jsontable JSONTo julianb karl Kaspersky kde KDM keycloak keycloakoidc keyform keygrip kiali kib Kinfo kiwano KNOWNFOLDERID kontainer kubeconfig Kubectx kubepods kuberlr Kubewarden kurrent kwctl ldconfig LGHT limactl limaiptables limaloc linds linenum linkname linode linuxkit Linuxy loadbalancer loblaw localnet logdna loglines logz lte mabels macarm machinedeployment machineset macx magog mcapps mediaselect messageformat metainfo metricsection mikey milli Minidriver minimizable missmatched mitm moar moby mockhelper moproxy mountinfo MRM msiexec MSIHANDLE MSIINSTALLPERUSER MSIX msixbundle msize msvs multierror multiport mungers myport namearray napanee navlink neq nerdctl netlink netns netsh networkpolicy neu newauth newfs newpid nginx ngx ninep noarch nocopy nologin nologo nomount NOPASSWD noprofile noproxy norc norestart normaliser NOSETENV nothrow notifempty notset npipe nsenter nsis NSISUNINSTALLCOMMAND nullglob nuxt nxt oapi objectset octicons openapitools openldapconfig openrc openresty openssh openstack opensuse opentelekomcloudcontainerengine operstate opsgenie oracleoke orsection osacompile osascript osc osxkeychain otccce overlayfs pagedown pageload pagerduty pageup parsesection pascalize pathspec pcs pdp persistentvolume persistentvolumeclaim PFlags pgid pidfd PII pikachu Pinganyun pinganyunecs plists plutil podmetrics podmonitor podsecuritypolicytemplate POLLIN POptions portbinding portforward portmap portmapping portproxy portstorage postgres POSTROUTING prakhar prebuilds preflights Privs PROCARGS procnet procnettcp progresskey projectroletemplatebinding Prometheis prometheusrule PROMPTROLLBACKCOST protip PROXYLIST PSelected PSHOME psps ptn publicdomain pushable pvc PWSTR qcow rackspace ramdisks rancherdesktop rancherkubernetesengine rawdata rcedit rcfiles rcompare rdctl rddepman rdinstall RDRUNAFTERINSTALL rdshell rdsudo rdtest rdvsock rdx readyz regexpsection regfiles registrationtoken registrytoken regtest remotedns replicaset replicationcontroller repofile requried resourcequota resourceset restclient restoretip reusecab rioinfo rke RLENGTH rmi rockylinux Roffline rolebinding roletemplate roletemplatebinding rootfs rosetta RSTART rtm Runas runbook runc rundir runlevel runtimeclass runtimeclasses rvf scaleio scanbenchmark scanprofile scanreport scinfo screencapture scriptdir scriptname scriptpath scrollback scrolllock sdl secretservice serveraddress servernum serviceaccount serviceapi servicemonitor servicewatcher setproxy sfc sharedscripts sharkanodo shasta shazbat shortkey shortlived showduplicates showmuted shuf sigch signtool singleparsesection sio skopeo sku SLAs SLIRP slo SLSA smanager smokris snakize softmmu someothername somepaththatshouldnevereverexist songgao spinkube splatform splunk ssd sshfs sslip Ssr STARTUPINFO statefulset stoinks storageclass storybookjs stringifying subkey subshells subvar sumologic SVCNAME svm syscalls sysfs tarballs TARGETDIR tcpip tcshrc TEAMID teamocil Telekom templ Tencent tencenttke termch testbuild testfilethatdoesntexist testid testreleasedate testurl thanosruler timekey timespan tini TKE tmpconfig tmpfiles tmutil toggleable togglefullscreen tonistiigi toolsets topmenu TQF traefik trivy TStr TVar tvf ucmonitor udhcpc UEFI unexpose unexposing unparsable upcloud Upgradable useb userdb userpreference UTCTIME vcenter vcpus vde VDropdown VERSIONSTRING veth Vhd vhdx vinzenz virtio virtiofs virtsock virtualnetwork virtualservice vishvananda VMGUID VMID vmnet vmwarevsphere vmx vnc vnode vpnkit vsock vtunnel vul vznat whatsnew wincred winio winthrop wix wixobj WIXUI wontfix wordpress wslconfig wslenv WSLg wslify wslinfo WSLINSTALLED WSLIs WSLKERNELOUTDATED wslproxy wslutils WWID wwn wws xauth XAUTHORITY xdev xec xfs xmlout xorg XVar xwininfo xyzzy xzf yamlfmt yarker youmightnotneedjquery Yubi zeroedthick zerowidthspace zipperhead ZSelected zst zstack zxf zypak zypp zypper ================================================ FILE: .github/actions/spelling/line_forbidden.patterns ================================================ # reject `m_data` as VxWorks defined it and that breaks things if it's used elsewhere # see [fprime](https://github.com/nasa/fprime/commit/d589f0a25c59ea9a800d851ea84c2f5df02fb529) # and [Qt](https://github.com/qtproject/qt-solutions/blame/fb7bc42bfcc578ff3fa3b9ca21a41e96eb37c1c7/qtscriptclassic/src/qscriptbuffer_p.h#L46) #\bm_data\b # Were you debugging using a framework with `fit()`? # If you have a framework that uses `it()` for testing and `fit()` for debugging a specific test, # you might not want to check in code where you skip all the other tests. #\bfit\( # English does not use a hyphen between adverbs and nouns # https://twitter.com/nyttypos/status/1894815686192685239 (?:^|\s)[A-Z]?[a-z]+ly-(?=[a-z]{3,})(?:[.,?!]?\s|$) # Smart quotes should match \s’[^.?!‘’]+’[^.?!‘’]+‘[^.?!‘’]+’|\s‘[^.?!‘’]+’[^.?!‘’]+’[^.?!‘’]+’|\s”[^.?!“”]+”[^.?!“”]+“[^.?!“”]+”|\s“[^.?!“”]+”[^.?!“”]+”[^.?!“”]+” # Don't write double negatives \w+n't not(?=\s) # Generally spaces follow instead of preceding `,`s # It's possible this is some strange CSV dialect, but, even so, you could probably move the space. \s[a-z]{3,} ,[a-z]{3,}\s # Generally words are written with `'s`, not `"s` [^=|+']\s\w+"s\s # Don't miswrite **irreversible binomials** # https://en.wikipedia.org/wiki/Irreversible_binomial (?i)\b(?:cheese and macroni|honey and milk|sweet and short|die or do|roll and rock|the bees and the birds|match and mix|tear and wear|clear and loud|death and life|span and spick|vigor and vim|abet and aid|says and deposes|means and ways|dryer and washer|relaxation and rest|famous and rich|loan and savings|come high\s?water or hell|tuck and nip|turf and surf|between a hard place and a rock|dime and five|mouse and cat|tired and sick|pregnant and barefoot|feathered and tarred|feathers and tar|subtraction and addition|liabilities and assets|forth and back|strikes and balls|end to beginning|white and black|small and big|bust or boom|groom and bride|sister and brother|pass and butt|sell and buy|release and catch|effect and cause|state and church|robbers and cops|go and come|going and coming|Indians and cowboys|nights and days|wide and deep|flow and ebb|ice and fire|last and first|ceiling to floor|drink and food|aft and fore|domestic and foreign|backward and forward|foe or friend|back to front|vegetables and fruits|take and give|evil and good|foot and hand|heels over head|Hell and Heaven|there and here|seek and hide|dale and hill|her and him|low and high|valleys and hills|hers and his|thither and hither|yon and hither|cold and hot|wife and husband|out and in|gentlemen and ladies|sea and land|death or life|short and long|found and lost|hate and love|war and love|wife and man|matter over mind|pop and mom|nice or naughty|far and near|tuck and nip|south to north|then and now|later and now|shut and open|under and over|ride and park|starboard and port|cons and pros|pull and push|file and rank|fall and rise|loan and savings|water and soap|finish to start|go and stop|dip and strike|sour and sweet|thin and thick|ring and tip|fro and to|bottom to top|country and town|down and up|downs and ups|downtown and uptown|peace and war|dryer and washer|wane and wax|no and yes|yang and yin|a curse and a blessing|don'ts and dos |farewell and hail|wait and hurry up|difference\s(?:\w+\s+)+day and night|in health and in sickness|from stern to stem|the dead and the quick|a place and a time|generations and ages|comfort and aid|alack and alas|pieces and bits|soul and body|early and bright|mortar and brick|jowl by cheek|tidy and clean|verse and chapter|saucer and cup|cents and dollars|loathing and fear|chips and fish|foremost and first|farewell and hail|fist over hand|shoulders and head|soul and heart|spices and herbs|home and house|thirst and hunger|fork and knife|bounds and leaps|behold and lo|tidy and neat|dime and nickel|cranny and nook|void and null|bolts and nuts|suffering and pain|quiet and peace|ink and pen|choose and pick|simple and plain|proper and prim|rave and rant|shoals and rocks|awe and shock|wonders and signs|bones and skull|crossbones and skull|narrow and strait|narrow and straight|strain and stress|roundabouts and swings|chiggers and ticks|complain and whine|rain and wind|amen and yea|(?:raised|bred) and born|by crook or by hook|(?<=it was a )stormy and dark(?= night)|(?<=this ) age and day|cross the t's and dot the i's|high minded and haughty|best and highest(?= use)|like daughter, like mother|done and over with|(?<=on ) needles and pins|half a dozen of the other, six of one|(?<=up ) personal and close|baggage and bag|beads and baubles|balance and beams|breakfast and bed|braces and belt|bar and bench|bad and big|bosh bash bish|blue and black|beautiful and bold|Baptists and bootleggers|briefs or boxers|butter and bread|boar and bull|carry and cash|cheese and chalk|clans and cliques|control and command|cream and cookies|dumb and deaf|dash and dine|dirty and down|drabs and dribs|drive and drink|disorderly and drunk|furious and fast|famine or feast|forget and fire|fury and fire|fauna and flora|forget and forgive|function and form|foe or friend|frolics and fun|feathers and fur|goblins and ghosts|giggles and grins|home and hearth|haw and hem|holler and hoot|handgrenades and horseshoes|Gentile and Jew|jiving and juking|country and king|caboodle and kit|kin and kith|longitude and latitude|limb and life|learn and live|load and lock|match and mix|mild and meek|number and name|parcel and part|pencil and pen|post to pillar|pans and pots|perish or publish|riches to rags|raving and ranting|write and read|rumble to ready|wrong and right|roll and rock|ready and rough|regulations and rules|secure and safe|sound and safe|shell and shot|shave and shower|symptoms and signs|slide and slip|span and spick|shine and spit|Stripes and Stars|stones and sticks|spice and sugar|that or this|tat for tit|tail and top|turn and toss|treat or trick|tribulations and trials|tested and tried|true and tried|trailer and truck|wear and wash|waiting and watching|wail and weep|wild and wet|hollering and whooping|woolly and wild|wonderful and wise|warlocks and witches|ruin and wrack|the bees and the birds|(?<=between the) deep blue sea and the devil|Dragons & Dungeons|fuck off or fit in|flop-flip|fancy-free and footloose|to hold and to have|least but not last|Lease-Lend|leave ['‘]em and love ['‘]em|leave it or love it|paper and pen(?:cil|)|patter-pitter|relaxation and rest|(?<=without )reason or rhyme|tacky-ticky|take and break|zoom and boom|cox and box|talk and chalk|darts and charts|dip and chips|drive and dive|square and fair|dime and five|jetsam and flotsam|dry and high|fire and hire|split and hit|thither and hither|trot to hot|puff and huff|bustle and hustle|gap and lap|greatest and latest|proud and loud|greet and meet|right makes might|shame and name|dear and near|sods and odds|upwards and onwards|about and out|proud and out|dump and pump|tough and rough|gun and run|clout and shout|bake and shake|surely but slowly|joke and smoke|dash and stash|bitch and stitch|drop and stop|turf and surf|tide and time|gown and town|bake and wake|tear and wear|feed and weed|dealing and wheeling|dine and wine|nay or yea|trouble double|bender fender|dandy-handy|panky-hanky|scarum-harum|skelter helter|piggledy higgledy|quit it and hit|pocus hocus|toity[- ]hoity|potch-hotch|burly-hurly|bitty-itty|bitsy-itsy|votor moter|the highway or my way|pamby-namby|claim it and name it|ever, never|gritty nitty|porgy orgy|mell-pell|baggy saggy|so good, so far|weeny-teeny|blue true|lose it or use it|nilly willy|(?<=the )nays and (?:the |)yeas|beyond and above|graces and airs|muster and alarm|kicking and alive|well and alive|dangerous and armed|oranges and apples|fill and back|forth and back|eggs and bacon|mash and bangers|switch and bait|tackle and bait|pregnant and barefoot|sale and bargain|breakfast and bed|call and beck|whistles and bells|suspenders and belt|bold and big|tall and big|better and bigger|purge and binge|bridle and bit|bobs and bits|pieces and bits|blue and black|tackle and block|guts and blood|gore and blood|weave and bob|arrow and bow|determined and bound|gagged and bound|scrape and bow|bit and brace|water and bread|circuses and bread|roses and bread|serve and brown|spade and bucket|grind and bump|run and bump|large and by|gown and cap|driver and car|mouse and cat|balances and checks|dumplings and chicken|change and chop|sober and clean|dagger and cloak|tie and coat|doughnuts and coffee|go and come|burn and crash|sugar and cream|punishment and crime|saucer and cup|paste and cut|run and cut|burdock and dandelion|night and day|buried and dead|gone and dead|taxes and death|dash and dine|conquer and divide|out and down|cover and duck|dive and duck|every and each|ears and eyes|figures and facts|wide and far|furious and fast|loose and fast|dandy and fine|thumbs and fingers|brimstone and fire|foremost and first|chips and fish|blood and flesh|bone and flesh|ever and forever|center and front|games and fun|bother and fuss|take and give|aspirations and goals|plenty and good|light and goodness|pound and ground|slash and hack|hearty and hale|fast and hard|eggs and ham|nail and hammer|sickle and hammer|tongs and hammer|minds and hearts|now and here|seek and hide|watch and hide|mighty and high|dry and high|tight and high|miss and hit|run and hit|yon and hither|thither and hither|hosed and home|dry and home|eye and hook|loop and hook|buggy and horse|carriage and horse|heavy and hot|high and hot|bothered and hot|puff and huff|when and if|custard and kippers|tell and kiss|kin and kith|fork and knife|screaming and kicking|streams and lakes|order and law|behold and lo|dam and lock|key and lock|feel and look|clear and loud|boy and man|potatoes and meat|women and men|cookies and milk|honey and milk|tenon and mortise|shakers and movers|address and name|faces and names|easy and nice|cranny and nook|crosses and noughts|bolts and nuts|ends and odds|away and off|done and one|about and out|out and over|terminer and oyer|cream and peaches|Qs and Ps|carrots and peas|axe and pick|moan and piss|vinegar and piss|whine and piss|proper and prim|booty and prize|cons and pros|beans and pork|simple and pure|dirty and quick|pinion and rack|ruin and rack|pillage and rape|famous and rich|fall and rise|shine and rise|board and room|tumble and rough|jump and run|pepper and salt|vinegar and salt|sniff and scratch|rescue and search|destroy and seek|tie and shirt|fat and short|sweet and short|stout and short|tell and show|jive and shuck|tired and sick|burn and slash|arrows and slings|fall and slip|steady and slow|grab and smash|mirrors and smoke|ladders and snakes|dance and song|fury and sound|polish and spit|deliver and stand|strain and stress|Drang und Sturm|debonair and suave|tie and suit|rainbows and sunshine|demand and supply|light and sweetness|sandal and sword|chairs and tables|thin and tall|feathers and tar|crumpets and tea|lightning and thunder|ass and tits|fro and to|nail and tooth|go and touch|field and track|error and trial|tribulations and trials|roll and tuck|turn and twist|about and up|coming and up|vigor and vim|see and wait|fuzzy and warm|weft and warp|ward and watch|wane and wax|means and ways|good and well|whine and whinge|roses and wine|phrases and words|no and yes|a leg and an arm|(?<=old )chain and ball|by golly and by guess|bull-and-cock|dried (dry) and cut|(?<=in this )age and day|pony and dog show|(?<=by )starts and fits|grin and bear it|(?<=move ) earth and heaven|quit it and hit it|kisses and hugs|(?<=for all )purposes and intents|make up and kiss|last testament and will|make do and mend|(?<=every ) then and now|for all and once(?=[,.;!?])|jelly and peanut butter|ice cream and pickles|raining dogs and cats|development and research|blues and rhythm|(?<=between a )hard place and a rock|(?<=all's )done and said|(?<=different )sizes and shapes|bones? and skin|(?<=in )spirit and (?:in |)truth|a miss and a swing|(?<=through )thin and thick|O's and X's|a day and a year|nothing or all|worse or better|small or big|white or black|pleasure or business|night or day|alive or dead|die or do|flight or fight|take or give|bad or good|simple or gentle|she or he|tails or heads|her or his|miss or hit|cure or kill|break or make|less or more|never or now|shine or rain|reason or rhyme|wrong or right|swim or sink|later or sooner|more or two|down or up|death or victory|lose or win|no or yes|the egg or (?:the |)chicken|(?<=neither )fowl nor fish|(?<=come )high water or hell|(?<=neither )there nor here|(?<=neither )hair nor hide|(?<=not one )tittle or jot|(?<=neither )money nor love|shut up or put up|leave it or take it|(?<=neither )ornament nor use|gatherer-hunter|cheese corn|Costello and Abbott|Isaac and Abraham|Patroclus and Achilles|Eve and Adam|Anicetus and Alexiares|Cleopatra and Antony|Ant & Dec|Robin and Batman|Clyde and Bonnie|Abel and Cain|Ball and Cannon|Pollux and Castor|Psyche and Cupid|Clack and Click|Pythias and Damon|Goliath and David|Guattari and Deleuze|Jane and Dick|Marguerite and Faust|Swann and Flanders|Saunders and French|Frack and Frick|Laurie and Fry|Sullivan and Gilbert|Aga and Gilgamesh|Gretel and Hansel|Hellman & Friedman|Esau and Jacob|Jill and Jack|Victor and Jack|Vijaya and Jaya|Jekyll & Hyde|Hardy and Laurel|McCartney and Lennon|Loewe and Lerner|Clark and Lewis|Lilo & Stitch|Large and Little|Meslamta-ea and Lugal-irra|Luigi and Mario|Lewis and Martin|Ashley and Mary-Kate Olsen|Sue and Mel|Wise and Morecambe|Mindy and Mork|Eurydice and Orpheus|Horse-Face and Ox-Head|Penn & Teller|Aristotle and Phyllis|Ferb and Phineas|Pinky & The Brain|Galatea and Pygmalion|Ren & Stimpy|Rhett & Link|Morty and Rick|Hart and Rodgers|Hammerstein and Rodgers|Juliet and Romeo|Remus and Romulus|Guildenstern and Rosencrantz|Max and Sam|Delilah and Samson|Simon & Garfunkel|Sonny & Cher|Thelma & Louise|Thompson and Thomson|Tom & Jerry|Isolde and Tristan|Tim & Eric|Adonis and Venus|Vic & Bob|Crick and Watson|Eve and Adam|pears and apples|glass and bottle|Liszt and Brahms|bone and dog|toad and frog|blister and hand|south and north|pork and rabbit|strife and trouble|eight and two|flute and whistle)\b # Don't use `requires that` + `to be` # https://twitter.com/nyttypos/status/1894816551435641027 \brequires that \w+\b[^.]+to be\b # A fully parenthetical sentence’s period goes inside the parentheses, not outside. # https://twitter.com/nyttypos/status/1898844061873639490 \([A-Z][a-z]{2,}(?: [a-z]+){3,}\)\.\s # Complete sentences shouldn't be in the middle of another sentence as a parenthetical. (?]|depth|frame|page|project|select)\s[1-9] (?!byte|day|hour|meaning|minute|month|(?:new |)page|people|(?:more |)space|year)[a-z]+ [a-z]+\s # Write out numbers at the start of a sentence # https://www.scribendi.com/academy/articles/when_to_spell_out_numbers_in_writing.en.html#:~:text=Beginning%20a%20Sentence%20with%20a%20Number,may%20be%2e (?:\b[a-z]{4,}|\s(?:[a-eg-z][a-z]{2}|f[a-hj-z][a-z]|fi[a-fh][a-z]))[:.?!] [1-9] [a-z]{3,} [a-z]+\s\w+ # Don't write two numbers in a row # https://www.scribendi.com/academy/articles/when_to_spell_out_numbers_in_writing.en.html#:~:text=Paired%20Numbers%20%28Two%20Numbers%20in,librarian%20to%20begin%20story%20time%2e (?:[a-z]{4,}|\s(?!apr|aug|dec|feb|fri|jan|mar|mon|nov|oct|sat|sep|sun|thu|tue|wed)[a-z]{3})\s\d+\s\d+(?!--)[-\s](?:(?!--)[-A-Za-z]){2,}\s # This probably indicates Mojibake https://en.wikipedia.org/wiki/Mojibake # You probably should try to unbake this content Ã(?:Â[¤¶¥]|[£¢])|à # Should be `HH:MM:SS` \bHH:SS:MM\b # Should be `86400` (seconds in a standard day) \b84600\b(?:.*\bday\b) # Should probably be `2006-01-02` (yyyy-mm-dd) # Assuming that the time is being passed to https://go.dev/src/time/format.go \b2006-02-01\b # Should probably have a trailing `.` \s([a-z]\.){2,}[a-z]\s # Should probably end with `”` # Likely bad OCR “.+[^'‘\\\[]+’'(?!['"]) # Should probably end with `”` or with only one of `’`/`'` \s\w+[^'‘\\\[]+’'(?!['"]) # Should probably be matching (smart)quotes or backticks (if Markdown) # Unless the file format is Tex (? Don't use `can not` when you mean `cannot`. The only time you're likely to see `can not` written as separate words is when the word `can` happens to precede some other phrase that happens to start with `not`. # > `Can't` is a contraction of `cannot`, and it's best suited for informal writing. # > In formal writing and where contractions are frowned upon, use `cannot`. # > It is possible to write `can not`, but you generally find it only as part of some other construction, such as `not only . . . but also.` # - if you encounter such a case, add a pattern for that case to patterns.txt. \b[Cc]an not\b(?! only\b) # Should be `chart` (?i)\bhelm\b.*\bchard\b # Should be `code` (?<=\bof )a code(?= that\b) # Should be `counter-intuitive` \bcounter intuitive\b # Do not use `(click) here` links # For more information, see: # * https://www.w3.org/QA/Tips/noClickHere # * https://webaim.org/techniques/hypertext/link_text # * https://granicus.com/blog/why-click-here-links-are-bad/ # * https://heyoka.medium.com/dont-use-click-here-f32f445d1021 (?i)(?:>|\[)(?:(?:click |)here|link|(?:read |)more)(?:> /etc/apt/sources.list.d/something-distro.list # ```` \bapt-key add\b # Should be `nearby` \bnear by\b # Should be `necessary` (?<=\blonger )needed(?= to\b) # Should probably be a person named `Nick` or the abbreviation `NIC` \bNic\b # Should be `not supposed` \bsupposed not\b # Should be `Once this` or `On this` or even `One that`. Rarely `One, this` [?!.] One this\b # Should probably be `much more` \bmore much\b # Should be `perform its` \bperform it's\b # Should be `PowerPoint` \bPowerpoint\b # Should be `opt-in` (? below for the` (?i)\bfind below the\b # Should be `then any` unless there's a comparison before the `,` , than any\b # Should be `did not exist` \bwere not existent\b # Should be `not exist` or `nonexistent` (?v# (?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_])) # hit-count: 77 file-count: 9 # Library prefix # e.g., `lib`+`archive`, `lib`+`raw`, `lib`+`unwind` # (ignores some words that happen to start with `lib`) (?:\b|_)[Ll]ib(?!era[lt])(?:re(?=office)|era|)(?!ero|erty|rar(?:i(?:an|es)|y))(?=[a-z]) # hit-count: 50 file-count: 14 # GitHub actions \buses:\s+(['"]?)[-\w.]+/[-\w./]+@[-\w.]+\g{-1} # hit-count: 44 file-count: 27 # node packages (["'])@[^/'" ]+/[^/'" ]+\g{-1} # hit-count: 40 file-count: 22 # C network byte conversions (?:\d|\bh)to(?!ken)(?=[a-z])|to(?=[adhiklpun]\() # hit-count: 29 file-count: 10 # in check-spelling@v0.0.22+, printf markers aren't automatically consumed # printf markers (?]) # hit-count: 2 file-count: 1 # iSCSI iqn (approximate regex) \biqn\.[0-9]{4}-[0-9]{2}(?:[\.-][a-z][a-z0-9]*)*\b:[\w.]+ # hit-count: 1 file-count: 1 # copyright Copyright (?:\([Cc]\)|)(?:[-\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+ # hit-count: 1 file-count: 1 # IPv6 \b(?:[0-9a-fA-F]{0,4}:){3,7}[0-9a-fA-F]{0,4}\b # hit-count: 1 file-count: 1 # javascript match regex \.match\(/[^/\s"]{3,}/[gim]*\s* # hit-count: 1 file-count: 1 # kubernetes crd patterns ^\s*pattern: .*$ # hit-count: 1 file-count: 1 # Windows Resources with accelerators \b[A-Z]&[a-z]+\b(?!;) # hit-count: 1 file-count: 1 # gist github \bgist\.github\.com/[^/\s"]+/[0-9a-f]+ # hit-count: 1 file-count: 1 # marker to ignore all code on line ^.*\bno-spell-check(?:-line|)(?:\s.*|)$ # Questionably acceptable forms of `in to` # Personally, I prefer `log into`, but people object # https://www.tprteaching.com/log-into-log-in-to-login/ \b(?:(?:[Ll]og(?:g(?=[a-z])|)|[Ss]ign)(?:ed|ing)?) in to\b # to opt in \bto opt in\b # typos in translation keys ^\s*(?:calllback|objectReferance|requriedInt):$ # pass(ed|ing) in \bpass(?:ed|ing) in\b # acceptable duplicates # ls directory listings [-bcdlpsw](?:[-r][-w][-SsTtx]){3}[\.+*]?\s+\d+\s+\S+\s+\S+\s+[.\d]+(?:[KMGT]|)\s+ # mount \bmount\s+-t\s+(\w+)\s+\g{-1}\b # C types and repeated CSS values \s(auto|await|buffalo|BUG|center|div|inherit|long|LONG|nobody|none|normal|solid|thin|TODO|transparent|very)(?:\s\g{-1})+\s # C enum and struct \b(?:enum|struct)\s+(\w+)\s+\g{-1}\b # go templates \s(\w+)\s+\g{-1}\s+\`(?:graphql|inject|json|yaml): # doxygen / javadoc / .net (?:[\\@](?:brief|defgroup|groupname|link|t?param|return|retval)|(?:public|private|\[Parameter(?:\(.+\)|)\])(?:\s+(?:static|override|readonly|required|virtual))*)(?:\s+\{\w+\}|)\s+(\w+)\s+\g{-1}\s # macOS file path (?:Contents\W+|(?!iOS)/)MacOS\b # Python package registry has incorrect spelling for macOS / Mac OS X "Operating System :: MacOS :: MacOS X" # "company" in Germany \bGmbH\b # IntelliJ \bIntelliJ\b # Commit message -- Signed-off-by and friends ^\s*(?:(?:Based-on-patch|Co-authored|Helped|Mentored|Reported|Reviewed|Signed-off)-by|Thanks-to): (?:[^<]*<[^>]*>|[^<]*)\s*$ # Autogenerated revert commit message ^This reverts commit [0-9a-f]{40}\.$ # ignore long runs of a single character: \b([A-Za-z])\g{-1}{3,}\b # Don't check names in dependabot.yml reviewers section ^\s+reviewers:\s*\[\s*"[^",]+"\s*\] # Directives to skip the current full line (intended for extension names and # their related account names): ^.*spellcheck-ignore-line.*$ # Don't check package names ^\s*zypper\b.*\binstall\b.* # Allow golangci in GitHub workflows: \buses:\s*golangci/golangci-lint-action\b # on macOS, MacOS is used for the internal folder name within an app... (["/])MacOS\g{-1} # GitHub owner names should not be checked (dependency scripts) \bgithubOwner\s*=\s*'.*?' # Win32 constants \bGWL_EXSTYLE\b \bHWND_NOTOPMOST\b \bSWP_NOMOVE\b \bSWP_NOSIZE\b # allow repetitive words in iptable rules DNAT\s+.*\s+anywhere anywhere # Image names \bghcr\.io/[A-Za-z0-9_/.-]+(?::[A-Za-z0-9_.-]+)?\b ================================================ FILE: .github/actions/spelling/reject.txt ================================================ attache aroynt.* bellows? benefitting occurences? .*dnt dependan.* developement developp?e Devers? devex.* devide Devinn?[ae] devisals? devisors? diables? hasta? hastat.* immediatly inisle inital linge oer Sorce [Ss]pae.* Teh untill untilling venders? wether.* ================================================ FILE: .github/actions/yarn-install/action.yaml ================================================ name: Yarn Install description: >- This is a composite action that does everything needed to do `yarn install`. runs: using: composite steps: # In case we're running on a self-hosted runner without `yarn` installed, # set up NodeJS, enable `yarn`, and then handle the caching. - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: package.json - run: corepack enable yarn shell: bash - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: package.json cache: yarn - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version-file: go.work cache-dependency-path: src/go/**/go.sum - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.x' cache: pip - run: pip install setuptools shell: bash - name: Install Windows dependencies if: runner.os == 'Windows' shell: powershell run: .\scripts\windows-setup.ps1 -SkipVisualStudio -SkipTools - name: Flag build for M1 if: runner.os == 'macOS' && runner.arch == 'ARM64' run: echo "M1=1" >> "${GITHUB_ENV}" shell: bash - run: yarn install --immutable shell: bash - name: Fix electron sandbox if: runner.os == 'Linux' shell: bash run: | sudo chown root node_modules/electron/dist/chrome-sandbox sudo chmod 04755 node_modules/electron/dist/chrome-sandbox ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directories: - "/" - "/.github/actions/*/" schedule: interval: "daily" cooldown: default-days: 7 open-pull-requests-limit: 12 labels: ["component/dependencies"] # Maintain dependencies for npm - package-ecosystem: "npm" directory: "/" schedule: interval: "daily" cooldown: default-days: 7 open-pull-requests-limit: 12 labels: ["component/dependencies"] ignore: - # Needs to be updated along with NodeJS version. dependency-name: "@types/node" update-types: [version-update:semver-major] - # We don't utilize @rancher/shell in a meaningful way. It is safe to # ignore until we arrive a solution that uses it. dependency-name: "@rancher/shell" versions: [">0.1"] # Maintain dependencies for Golang - package-ecosystem: "gomod" directories: - "/src/go/*" - "/src/go/**/*" - "/scripts" schedule: interval: "daily" # cooldown: # default-days: 7 open-pull-requests-limit: 12 labels: ["component/dependencies"] ignore: - # Swagger dependencies must match whatever the CLI generate. dependency-name: "github.com/go-openapi/swag" groups: golang-x: patterns: ["golang.org/x/*"] k8s: patterns: ["k8s.io/*"] ================================================ FILE: .github/workflows/bats/get-tests.py ================================================ #!/usr/bin/env python3 # This script determines the tests to be run. # Inputs (as environment variables, all are space-separated): # TESTS The set of tests to run (e.g. "*", "containers k8s") # PLATFORMS The set of platforms (e.g. "linux mac") # ENGINES The set of engines (e.g. "containerd moby") # KUBERNETES_VERSION The default Kubernetes version to use # KUBERNETES_ALT_VERSION Alternative Kubernetes version for coverage # The working directory must be the "bats/tests/" folder import dataclasses import glob import json from operator import attrgetter import os import sys from typing import Iterator, List, Literal, get_args Platforms = Literal["linux", "mac", "win"] Hosts = Literal["ubuntu-latest", "macos-15-intel", "windows-latest"] Engines = Literal["containerd", "moby"] @dataclasses.dataclass class Result: """ A Result describes a test run, which is a matrix entry. """ # The name of the test; either a directory or a file name (without extension) name: str host: Hosts engine: Engines # The version of k3s to test k3sVersion: str # A different Kubernetes version, for testing upgrades. k3sAltVersion: str key = staticmethod(attrgetter("name", "host", "engine")) def resolve_test(test: str, platform: Platforms) -> Iterator[str]: """ Given a test spec, convert that to a list of tests. """ # If we can't glob the test, use it as-is. for test in glob.glob(test) or (test,): if platform == "mac" and test == "k8s": # The macOS runners on CI are extra slow; for this test suite, # run each test individually. for name in glob.glob("k8s/*.bats"): yield name.removesuffix(".bats") else: yield test.removesuffix(".bats") def skip_test(test: Result) -> bool: """ Check if a given test should be skipped. We skip some tests because the CI machines can't handle them. """ if test.host == "macos-15-intel" and test.name.startswith("k8s/"): # The macOS CI runners are slow; skip some tests that can be tested on # other OSes. skipped_tests = ("verify-cached-images",) if any(test.name == f"k8s/{t}" for t in skipped_tests): return True return False results: List[Result] = list() errors: bool = False for test in (os.environ.get("TESTS", None) or "*").split(): platforms: List[Platforms] = os.environ.get("PLATFORMS", "").split() or get_args(Platforms) engines: List[Engines] = os.environ.get("ENGINES", "").split() or get_args(Engines) for platform in platforms: host: Hosts = { "linux": "ubuntu-latest", "mac": "macos-15-intel", "win": "windows-latest", }[platform] for name in resolve_test(test, platform): for engine in engines: if os.access(name, os.R_OK): pass elif os.access(f"{name}.bats", os.R_OK): name = f"{name}.bats" else: errors = True print(f"Failed to find test {name}", file=sys.stderr) continue # To get some coverage of different Kubernetes versions, pick the # version depending on the container engine; one gets the old version # we previously tested, the other gets the maximum version # of k3s that is supported by the Rancher helm chart. These values # come from the environment. k3sVersion = os.environ.get("KUBERNETES_VERSION", "") k3sAltVersion = os.environ.get("KUBERNETES_ALT_VERSION", "") if k3sVersion == "" or k3sAltVersion == "": raise "Either KUBERNETES_VERSION or KUBERNETES_ALT_VERSION is unset" if engine == "containerd": (k3sAltVersion, k3sVersion) = (k3sVersion, k3sAltVersion) result = Result(name=name, host=host, engine=engine, k3sVersion=k3sVersion, k3sAltVersion=k3sAltVersion) if not skip_test(result): results.append(result) dicts = [dataclasses.asdict(x) for x in sorted(results, key=Result.key)] output = os.environ.get("GITHUB_OUTPUT", None) if output is not None: with open(output, "a") as file: print(f"tests={json.dumps(dicts)}", file=file) json.dump(dicts, sys.stdout, indent=2) if errors: raise FileNotFoundError("Some tests were not found") ================================================ FILE: .github/workflows/bats/sanitize-artifact-name.sh ================================================ #!/bin/bash set -o errexit -o nounset -o pipefail # GitHub restricts artifact filenames: # Invalid characters include: Double quote ", Colon :, Less than <, # Greater than >, Vertical bar |, Asterisk *, Question mark ?, Carriage # return \r, Line feed \n # # The following characters are not allowed in files that are uploaded # due to limitations with certain file systems such as NTFS. To maintain # file system agnostic behavior, these characters are intentionally not # allowed to prevent potential problems with downloads on different file # systems. # By default, this script takes a string on standard input and outputs the # sanitized string on standard output. If any positional parameters are given, # it instead treats them as file names to (recursively) rename. sanitize() { local new=$1 new=${new//\"/%22} new=${new//:/%3A} new=${new///%3E} new=${new//|/%7C} new=${new//\*/%2A} new=${new//\?/%3F} new=${new//$'\r'/} new=${new//$'\n'/} echo "$new" } if [[ ${#@} -lt 1 ]]; then # No arguments; sanitize standard input. sanitize "$(cat)" exit fi # Find all files and put the names into the FILES array. # We don't rename inside the loop to make sure the find command has # finished before we modify any directories it is iterating over. FILES=() for PARAM in "$@"; do while read -d $'\0' -r FILE; do FILES+=("$FILE") done < <(find "$PARAM" -type f -print0) done for FILE in "${FILES[@]}"; do NEW="$(sanitize "$FILE")" if [[ $FILE != "$NEW" ]]; then echo "$NEW" mv "$FILE" "$NEW" fi done ================================================ FILE: .github/workflows/bats/summarize.mjs ================================================ // This file creates the summary table at the end of the run. // // Inputs: // */version.txt -- The version of Rancher Desktop tested // */name.txt -- The test suite that was ran // */os.txt -- The OS the test was run on // */engine.txt -- The container engine used // */log-name.txt -- The name of the logs artifact // */report.tap -- The results // Environment: // GITHUB_API_URL, GITHUB_RUN_ID, GITHUB_REPOSITORY, GITHUB_SERVER_URL // See https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables // GITHUB_TOKEN // GitHub authorization token. // @ts-check import fs from 'fs'; import path from 'path'; /** * Define interface for emitting one line of output. * @typedef {(line: string) => unknown} OutputMethod */ class Run { /** Contents of version.txt. */ versionData = ''; /** Contents of name.txt. */ name = ''; /** Contents of os.txt. */ os = ''; /** Contents of engine.txt. */ engine = ''; /** Contents of log-name.txt. */ logName = ''; /** Total number of tests. */ total = 0; /** Number of tests passed (not skipped). */ passed = 0; /** Number of tests skipped. */ skipped = 0; /** Number of tests failed. */ failed = 0; /** Job ID; this may not be set. */ id = 0; /** ID for the logs artifact; might be missing. */ logId = 0; /** Number of tests passed or skipped. */ get ok() { return this.passed + this.skipped }; /** Whether this run succeeded. */ get succeeded() { return this.ok == this.total }; /** Version string for this run. */ get version() { let v = this.versionData; for (const prefix of ['Rancher Desktop-', 'rancher-desktop-', 'Rancher.Desktop.Setup.']) { if (v.startsWith(prefix)) { v = v.substring(prefix.length); } } const suffixes = ['.msi']; for (const platform of ['linux', 'arm64-mac', 'mac', 'win']) { suffixes.push(`-${ platform }.zip`); } for (const suffix of suffixes) { if (v.endsWith(suffix)) { v = v.substring(0, v.length - suffix.length); } } return v; } /** The column for this run. */ get column() { return `${ this.os } ${ this.engine }` } } /** * Read the runs in the current directory. * @returns {Promise} */ async function readRuns() { /** @type Run[] */ const runs = []; for (const entry of await fs.promises.readdir('.', { withFileTypes: true })) { if (!entry.isDirectory()) { continue; } try { /** * Return the contents of a file relative to the entry directory. * @param {string} relPath The name of the file to read. * @returns {Promise} Trimmed contents of the file. */ async function readFile(relPath) { const fullPath = path.join(entry.name, relPath); return (await fs.promises.readFile(fullPath, { encoding: 'utf-8' })).trimEnd(); } const run = new Run(); run.versionData = await readFile('version.txt'); run.name = await readFile('name.txt'); run.os = await readFile('os.txt'); run.engine = await readFile('engine.txt'); run.logName = await readFile('log-name.txt'); const report = await fs.promises.open(path.join(entry.name, 'report.tap')); for await (const line of report.readLines()) { if (line.startsWith('1..')) { run.total = parseInt(line.substring(3), 10); } else if (line.toLowerCase().includes(' # skip')) { run.skipped++; } else if (line.startsWith('ok ')) { run.passed++; } else if (line.startsWith('no ok ')) { run.failed++; } } runs.push(run); } catch (ex) { // We might be reading `.git`, `.github`, etc; don't abort if we failed to // read anything, but record the error for debugging purposes. console.error(`Failed to read ${ entry.name }:`, ex); } } // We don't have job ID and artifact ID from the recorded data (because those // are not available to the jobs as they are run); try to fetch them. await updateRunInfo(runs); return runs; } /** * Print the version string table. * @param {Run[]} runs The runs collected. * @param {OutputMethod} output Function to output a line. */ async function printVersions(runs, output) { /** @type Set */ const versions = new Set(); for (const run of runs) { versions.add(run.version); } output('Versions\n---'); for (const version of Array.from(versions).sort()) { output('`' + version + '`'); } output(''); } /** * Minimal structure of a /jobs API return. * @typedef {Object} GitHubWorkflowJobList * @property {GitHubWorkflowRunJob[]} jobs */ /** * @typedef {Object} GitHubWorkflowRunJob * @property {number} id * @property {string} name */ /** * Minimal structure of a /artifacts API return. * @typedef {Object} GitHubWorkflowArtifactsList * @property {GitHubWorkflowArtifact[]} artifacts */ /** * @typedef {Object} GitHubWorkflowArtifact * @property {number} id * @property {string} name */ /** * Fetch GitHub metadata about the current run. * @param {'jobs' | 'artifacts'} infoType The information to get. * @returns {Promise} The data from API, or undefined. */ async function getRunMetadata(infoType) { const { env } = process; const variables = [ 'GITHUB_API_URL', 'GITHUB_RUN_ID', 'GITHUB_REPOSITORY', ]; for (const variable of variables) { if (!(variable in env)) { console.error(`${ variable } not set, skipping GitHub API calls`); return; } } const url = `${ env.GITHUB_API_URL }/repos/${ env.GITHUB_REPOSITORY }/actions/runs/${ env.GITHUB_RUN_ID }/${ infoType }?per_page=100`; /** @type Record */ const headers = {}; if ('GITHUB_TOKEN' in env) { headers.Authorization = `Bearer ${ env.GITHUB_TOKEN }`; } const response = await fetch(url, { headers }) if (!response.ok) { throw new Error(`Failed to get GitHub ${ infoType } info:` + await response.text()); } return await response.json(); } /** * Update runs in place with metadata from GitHub. * @param {Run[]} runs The runs to modify. */ async function updateRunInfo(runs) { /** @type GitHubWorkflowJobList | undefined */ const jobInfo = await getRunMetadata('jobs'); if (jobInfo) { // Parse the info to get a list of job matrix values to job ID. // Because there may be more values than the ones we're looking for, we can't // just make it a Map. const jobMap = jobInfo.jobs.map(job => { const name = (/\((.*)\)/.exec(job.name) ?? [])[1]; const vals = new Set((name?.split(',') ?? []).map(n => n.trim())); return /** @type {const} */([vals, job.id]); }); for (const run of runs) { const [, id]= jobMap.find(([vals]) => { return vals.has(run.name) && vals.has(run.os) && vals.has(run.engine); }) ?? []; if (id) { run.id = id; } } } /** @type GitHubWorkflowArtifactsList | undefined */ const artifactInfo = await getRunMetadata('artifacts'); if (artifactInfo) { const artifactMap = Object.fromEntries(artifactInfo.artifacts.map(a => [a.name, a.id])); for (const run of runs) { if (run.logName in artifactMap) { run.logId = artifactMap[run.logName]; } } } } /** * Print the result table * @param {Run[]} runs The runs collected * @param {OutputMethod} output Function to output a line */ async function printResults(runs, output) { if (!process.env.EXPECTED_TESTS) { throw new Error('EXPECTED_TESTS was not set'); } /** @type {{name: string, host: string, engine: string}[]} */ const expectedTests = JSON.parse(process.env.EXPECTED_TESTS); const expectedNames = Array.from(new Set(expectedTests.map(t => t.name))).sort(); const expectedHosts = Array.from(new Set(expectedTests.map(t => t.host))).sort(); const expectedColumns = expectedHosts.map(host => { const engines = new Set(expectedTests.filter(t => t.host === host).map(t => t.engine)); return Array.from(engines).sort().map(engine => [host, engine]); }).flat(1); output(['Name', ...expectedColumns.map(parts => parts.join(' '))].join(' | ')); output(['', ...expectedColumns].map(() => '---').join(' | ')); for (const name of expectedNames) { const row = [name]; for (const [host, engine] of expectedColumns) { const run = runs.find(r => r.name === name && r.os === host && r.engine === engine); const expected = expectedTests.find(t => t.name === name && t.host === host && t.engine === engine); if (run) { const emoji = run.succeeded ? ':white_check_mark:' : ':x:'; const count = run.succeeded ? '' : `${ run.ok }/${ run.total }`; let tooltip = ''; tooltip += run.passed ? `${ run.passed } passed ` : ''; tooltip += run.failed ? `${ run.failed } failed ` : ''; tooltip += run.skipped ? `${ run.skipped } skipped ` : ''; tooltip += `out of ${ run.total }`; const { env } = process; let result = ''; if (run.logId) { const url = `${ env.GITHUB_SERVER_URL }/${ env.GITHUB_REPOSITORY }/actions/runs/${ env.GITHUB_RUN_ID}/artifacts/${ run.logId }`; result += `:file_folder: `; } result += `${ emoji } ${ count }`; row.push(result); } else if (expected) { // The test result is missing for this run. row.push(':x: ??'); } else { // This combination is not run. row.push(''); } } output(row.join(' | ')); } } (async() => { const runs = await readRuns(); for (const run of runs) { console.log(run); } /** @type {OutputMethod} */ let output = console.log; if (process.env.GITHUB_STEP_SUMMARY) { const file = await fs.promises.open(process.env.GITHUB_STEP_SUMMARY, 'a'); output = (line) => file.write(line + '\n'); } await printVersions(runs, output); await printResults(runs, output); })().catch(ex => { console.error(ex); process.exit(1); }); ================================================ FILE: .github/workflows/bats.yaml ================================================ name: BATS on: workflow_dispatch: inputs: owner: description: Override owner (e.g. rancher-sandbox) type: string repo: description: Override repository (e.g. rancher-desktop) type: string branch: description: Override branch (e.g. main, or PR#) type: string tests: description: 'Tests (in the tests/ directory, e.g. "containers")' default: '*' type: string platforms: description: Platforms to run default: 'linux mac win' type: string engines: description: Container engines to run default: 'containerd moby' type: string kubernetes-version: description: Primary Kubernetes version to test default: '1.22.7' # Must also change in calculate step type: string kubernetes-alt-version: description: Secondary Kubernetes version to test (e.g. for upgrades) default: '1.28.11' # Must also change in calculate step type: string package-id: description: Package run ID override; leave empty to use latest. default: '' type: string experimental: description: Run with experimental settings (WSL) default: false type: boolean schedule: - cron: '0 8 * * 1-5' # 8AM UTC weekdays as a baseline permissions: contents: read env: GH_OWNER: ${{ github.repository_owner }} GH_REPOSITORY: ${{ github.repository }} GH_REF_NAME: ${{ github.ref_name }} jobs: get-tests: name: Calculate tests to run runs-on: ubuntu-latest steps: - name: Fetch install script uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false sparse-checkout-cone-mode: false sparse-checkout: | scripts/install-latest-ci.sh .github/workflows/bats/get-tests.py - id: repo name: Calculate short repository run: echo "repo=${GITHUB_REPOSITORY#*/}" >> "$GITHUB_OUTPUT" - name: Fetch tests run: | : ${OWNER:=$GH_OWNER} : ${REPO:=${GH_REPOSITORY#$GH_OWNER/}} : ${BRANCH:=$GH_REF_NAME} # If BRANCH is a number, assume it is supposed to be a PR [[ $BRANCH =~ ^[0-9]+$ ]] && export PR=$BRANCH "scripts/install-latest-ci.sh" env: GH_TOKEN: ${{ github.token }} OWNER: ${{ inputs.owner || github.repository_owner }} REPO: ${{ inputs.repo || steps.repo.outputs.repo }} BRANCH: ${{ inputs.branch || github.ref_name }} ID: ${{ inputs.package-id }} BATS_DIR: ${{ github.workspace }}/bats INSTALL_MODE: skip - name: Calculate tests id: calculate # This script is not inline to make local testing easier run: python3 ${{ github.workspace }}/.github/workflows/bats/get-tests.py env: TESTS: ${{ inputs.tests }} PLATFORMS: ${{ inputs.platforms }} ENGINES: ${{ inputs.engines }} KUBERNETES_VERSION: ${{ inputs.kubernetes-version || '1.22.7' }} # rancher/rancher helm chart 2.8.5 supports up to 1.28.* KUBERNETES_ALT_VERSION: ${{ inputs.kubernetes-alt-version || '1.28.11' }} working-directory: bats/tests outputs: repo: ${{ steps.repo.outputs.repo }} tests: ${{ steps.calculate.outputs.tests }} bats: needs: get-tests strategy: fail-fast: false matrix: include: ${{ fromJSON(needs.get-tests.outputs.tests )}} runs-on: ${{ matrix.host }} steps: - name: Fetch install script uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false sparse-checkout-cone-mode: false sparse-checkout: | scripts/install-latest-ci.sh .github/actions/setup-environment/action.yaml .github/workflows/bats/sanitize-artifact-name.sh - name: Install latest CI build run: | : ${OWNER:=$GH_OWNER} : ${REPO:=${GH_REPOSITORY#$GH_OWNER/}} : ${BRANCH:=$GH_REF_NAME} # If BRANCH is a number, assume it is supposed to be a PR [[ $BRANCH =~ ^[0-9]+$ ]] && export PR=$BRANCH scripts/install-latest-ci.sh shell: bash env: GH_TOKEN: ${{ github.token }} OWNER: ${{ inputs.owner || github.repository_owner }} REPO: ${{ inputs.repo || needs.get-tests.outputs.repo }} BRANCH: ${{ inputs.branch || github.ref_name }} ID: ${{ inputs.package-id }} BATS_DIR: ${{ github.workspace }}/bats INSTALL_MODE: installer ZIP_NAME: ${{ github.workspace }}/version.txt RD_LOCATION: system - name: Set up environment uses: ./.github/actions/setup-environment - name: "Linux: Install prerequisites" if: runner.os == 'Linux' run: >- sudo DEBIAN_FRONTEND=noninteractive apt-get install coreutils - name: "macOS: Install prerequisites" if: runner.os == 'macOS' shell: bash run: brew install --force bash coreutils - name: "Windows: Install WSL2 Distribution" if: runner.os == 'Windows' shell: pwsh run: | # Install a "modern" WSL distribution wsl --install --no-launch --web-download --name openSUSE openSUSE-Leap-15.6 # Prevent first-boot from running wsl --distribution openSUSE --user root --exec sed -i -e '/^command/d' /etc/wsl-distribution.conf # Create the initial user wsl --distribution openSUSE --user root --exec /usr/sbin/useradd --create-home --uid 1000 user - name: "Windows: Install prerequisites in WSL" if: runner.os == 'Windows' shell: pwsh run: >- wsl.exe --distribution openSUSE --user root --exec zypper --non-interactive install curl util-linux - name: "Windows: Enable experimental WSL settings" if: runner.os == 'Windows' && inputs.experimental shell: pwsh run: | Set-Content -Encoding UTF8NoBOM -Path "${HOME}/.wslconfig" -Value @" ; Note that not all settings here make sense together. [wsl] dnsProxy=false ; networkingMode=mirrored ; https://github.com/rancher-sandbox/rancher-desktop/issues/6665 firewall=true dnsTunneling=true autoProxy=true [experimental] autoMemoryReclaim=gradual sparseVhd=true useWindowsDnsCache=true bestEffortDnsParsing=true hostAddressLoopback=true "@ - name: Set log directory shell: bash run: | echo "LOGS_DIR=$(pwd)/logs" >> "$GITHUB_ENV" mkdir logs - name: "Windows: Override log directory" if: runner.os == 'Windows' shell: powershell run: >- wsl.exe --distribution openSUSE -- echo 'LOGS_DIR=$(pwd)' | Out-File -Encoding ASCII -Append "$ENV:GITHUB_ENV" working-directory: logs - name: Normalize test name id: normalize shell: bash run: | t="${{ matrix.name }}" if [[ ! -r "tests/$t" ]] && [[ -r "tests/${t}.bats" ]]; then t="${t}.bats" fi echo "test=$t" >> "$GITHUB_OUTPUT" working-directory: bats - name: "macOS: Set startup command" if: runner.os == 'macOS' run: echo "BATS_COMMAND=$BATS_COMMAND" >> "$GITHUB_ENV" env: BATS_COMMAND: exec - name: "Linux: Set startup command" if: runner.os == 'Linux' run: echo "BATS_COMMAND=$BATS_COMMAND" >> "$GITHUB_ENV" env: BATS_COMMAND: >- exec xvfb-run --auto-servernum --server-args='-screen 0 1280x960x24' - name: "Windows: Set startup command" if: runner.os == 'Windows' shell: bash run: echo "BATS_COMMAND=$BATS_COMMAND" >> "$GITHUB_ENV" env: BATS_COMMAND: wsl.exe --distribution openSUSE --exec - name: Run BATS # We use ${{ env.BATS_COMMAND }} instead of ${BATS_COMMAND} to let the # shell parse the command, instead of doing it via expansion which is then # parsed differently (--server-args isn't kept as one word). Also, we # need to use the env.* form because PowerShell uses ${ENV:VAR} instead. run: >- ${{ env.BATS_COMMAND }} ./bats-core/bin/bats --gather-test-outputs-in '${{ env.LOGS_DIR }}' --print-output-on-failure --filter-tags '!ci-skip' --formatter cat --report-formatter tap 'tests/${{ steps.normalize.outputs.test }}' env: BATS_COMMAND: ${{ env.BATS_COMMAND }} GITHUB_TOKEN: ${{ github.token }} LOGS_DIR: ${{ env.LOGS_DIR }} RD_CAPTURE_LOGS: "true" RD_CONTAINER_ENGINE: ${{ matrix.engine }} RD_KUBERNETES_VERSION: ${{ matrix.k3sVersion }} RD_KUBERNETES_ALT_VERSION: ${{ matrix.k3sAltVersion }} RD_TAKE_SCREENSHOTS: "true" RD_TRACE: "true" RD_USE_GHCR_IMAGES: "true" RD_USE_RAMDISK: "true" RD_USE_WINDOWS_EXE: "${{ runner.os == 'Windows' }}" WSLENV: "\ GITHUB_TOKEN:\ RD_CAPTURE_LOGS:\ RD_CONTAINER_ENGINE:\ RD_KUBERNETES_VERSION:\ RD_KUBERNETES_ALT_VERSION:\ RD_TAKE_SCREENSHOTS:\ RD_TRACE:\ RD_USE_GHCR_IMAGES:\ RD_USE_RAMDISK:\ RD_USE_WINDOWS_EXE:\ " working-directory: bats timeout-minutes: 120 - name: Calculate log name id: log_name if: ${{ !cancelled() }} run: | name="$(.github/workflows/bats/sanitize-artifact-name.sh <<< "$name")" # For the artifact name, backslash and forward slash are also invalid. name=${name//\\/%3C} name=${name//\//%2F} echo "name=$name" >>"$GITHUB_OUTPUT" shell: bash env: name: ${{ matrix.host }}-${{ matrix.engine }}-${{ matrix.name }}.logs - name: Consolidate logs if: ${{ !cancelled() }} run: | # bats/logs may not exist if the workflow is being tested with e.g. tests/helpers/utils.bats if [ -d "bats/logs" ]; then cp -R "bats/logs/" logs fi cp "bats/report.tap" logs .github/workflows/bats/sanitize-artifact-name.sh logs echo "$NAME" > logs/name.txt echo "$OS" > logs/os.txt echo "$ENGINE" > logs/engine.txt echo "$LOG_NAME" > logs/log-name.txt mv version.txt logs/ shell: bash env: NAME: ${{ matrix.name }} OS: ${{ matrix.host }} ENGINE: ${{ matrix.engine }} LOG_NAME: ${{ steps.log_name.outputs.name }} - name: Upload logs if: ${{ !cancelled() }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ steps.log_name.outputs.name }} path: logs/ if-no-files-found: error summarize: name: Summarize output needs: [ get-tests, bats ] if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - name: Fetch summarizer script uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false sparse-checkout-cone-mode: false sparse-checkout: | package.json .github/workflows/bats/summarize.mjs - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: package.json - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: "*.logs" - run: node .github/workflows/bats/summarize.mjs env: EXPECTED_TESTS: ${{ needs.get-tests.outputs.tests }} ================================================ FILE: .github/workflows/codeql.yaml ================================================ name: "CodeQL Advanced" on: push: branches: ["main", "release*"] pull_request: branches: ["main", "release*"] schedule: - cron: '33 19 * * 5' workflow_dispatch: jobs: analyze: name: Analyze (${{ matrix.language }}) # Runner size impacts CodeQL analysis time. To learn more, please see: # - https://gh.io/recommended-hardware-resources-for-running-codeql # - https://gh.io/supported-runners-and-hardware-resources # - https://gh.io/using-larger-runners (GitHub.com only) # Consider using larger runners or machines with greater resources for possible analysis time improvements. runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} permissions: # required for all workflows security-events: write # required to fetch internal or private CodeQL packs packages: read # only required for workflows in private repositories actions: read contents: read strategy: fail-fast: false matrix: include: - language: go build-mode: autobuild - language: javascript-typescript build-mode: none # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' # Use `c-cpp` to analyze code written in C, C++ or both # Use 'java-kotlin' to analyze code written in Java, Kotlin or both # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # If the analyze step fails for one of the languages you are analyzing with # "We were unable to automatically build your code", modify the matrix above # to set the build mode to "manual" for that language. Then modify this step # to build your code. # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - if: matrix.build-mode == 'manual' shell: bash run: | echo 'If you are using a "manual" build mode for one or more of the' \ 'languages you are analyzing, replace this with the commands to build' \ 'your code, for example:' echo ' make bootstrap' echo ' make release' exit 1 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 with: category: "/language:${{matrix.language}}" ================================================ FILE: .github/workflows/docker-cli-monitor.yaml ================================================ name: Check for new releases of docker/cli on: schedule: - cron: '55 8 * * *' workflow_dispatch: {} jobs: check-for-token: outputs: has-token: ${{ steps.calc.outputs.HAS_SECRET }} runs-on: ubuntu-latest steps: - id: calc run: echo "HAS_SECRET=${HAS_SECRET}" >> "${GITHUB_OUTPUT}" env: HAS_SECRET: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW != '' }} check-docker-cli: needs: check-for-token if: needs.check-for-token.outputs.has-token == 'true' runs-on: ubuntu-latest permissions: issues: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - uses: ./.github/actions/yarn-install - run: yarn dcmonitor env: GITHUB_CREATE_TOKEN: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW }} GITHUB_TOKEN: ${{ github.token }} ================================================ FILE: .github/workflows/k3s-versions.yaml ================================================ name: Update k3s-versions.json on: schedule: - cron: '43 8 * * *' workflow_dispatch: {} permissions: contents: write pull-requests: write jobs: check-for-token: outputs: has-token: ${{ steps.calc.outputs.HAS_SECRET }} runs-on: ubuntu-latest steps: - id: calc run: echo "HAS_SECRET=${HAS_SECRET}" >> "${GITHUB_OUTPUT}" env: HAS_SECRET: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW != '' }} check-update-versions: needs: check-for-token if: needs.check-for-token.outputs.has-token == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # we may need to checkout an existing branch, so need the full history fetch-depth: 0 # Setup go to be able to run `go run ./scripts/k3s-version.go` - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version-file: go.work - run: ./scripts/k3s-versions.sh env: GITHUB_TOKEN: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW }} ================================================ FILE: .github/workflows/linux-e2e.yaml ================================================ name: e2e tests on Linux on: workflow_dispatch: push: branches-ignore: - 'dependabot/**' pull_request: {} jobs: check-paths: uses: ./.github/workflows/paths-ignore.yaml e2e-tests: needs: check-paths if: needs.check-paths.outputs.should-run == 'true' timeout-minutes: 150 runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: ./.github/actions/yarn-install - name: Disable admin-access before start up run: | mkdir -p $HOME/.config/rancher-desktop cat < $HOME/.config/rancher-desktop/settings.json { "version": 5, "application": { "adminAccess": false } } EOF - name: Enable kvm access run: sudo chmod a+rwx /dev/kvm - name: Run e2e Tests continue-on-error: false run: >- xvfb-run --auto-servernum --server-args='-screen 0 1280x960x24' yarn test:e2e env: RD_DEBUG_ENABLED: '1' CI: true timeout-minutes: 150 - name: Upload failure reports uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: always() with: name: failure-reports.zip path: ./e2e/reports/* - name: Clean up test environment run: | rm -f $HOME/.config/rancher-desktop.defaults.json rm -f $HOME/.config/rancher-desktop.locked.json if: always() ================================================ FILE: .github/workflows/linux-release.yaml ================================================ name: Upload Linux release on: release: types: - published workflow_dispatch: {} defaults: run: shell: bash jobs: linux-release: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false fetch-depth: 0 - name: Populate necessary env vars run: | # get the env vars version_with_v=${GITHUB_REF#"refs/tags/"} release_zip_name="rancher-desktop-linux-${version_with_v}.zip" major_minor=$(echo ${version_with_v} | sed -E 's/v([0-9]+\.[0-9]+)\.[0-9]+.*/\1/g') s3_zip_name="rancher-desktop-linux-${major_minor}.zip" # make variables available in subsequent steps echo "version_with_v=$version_with_v" >> $GITHUB_ENV echo "release_zip_name=$release_zip_name" >> $GITHUB_ENV echo "major_minor=$major_minor" >> $GITHUB_ENV echo "s3_zip_name=$s3_zip_name" >> $GITHUB_ENV - run: mkdir -p dist - name: Fetch the .zip file from release run: >- curl -L -o "dist/${release_zip_name}" "https://github.com/${repository}/releases/download/${version_with_v}/${release_zip_name}" env: repository: ${{ github.repository }} version_with_v: ${{ env.version_with_v }} release_zip_name: ${{ env.release_zip_name }} - name: Upload zip file to S3 run: >- aws s3 cp "dist/${release_zip_name}" "s3://rancher-desktop-assets-for-obs/${s3_zip_name}" env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: us-east-1 release_zip_name: ${{ env.release_zip_name }} s3_zip_name: ${{ env.s3_zip_name }} - name: Trigger OBS services for relevant package in stable channel run: >- curl -X POST -H "Authorization: Token ${OBS_WEBHOOK_TOKEN}" "https://build.opensuse.org/trigger/runservice?project=isv:Rancher:stable&package=rancher-desktop-${MAJOR_MINOR}" env: MAJOR_MINOR: ${{ env.major_minor }} OBS_WEBHOOK_TOKEN: ${{ secrets.OBS_WEBHOOK_TOKEN }} ================================================ FILE: .github/workflows/macM1-e2e.yaml ================================================ name: e2e tests on Mac M1 on: workflow_dispatch: schedule: - cron: '15 8 * * 1-5' jobs: e2e-tests: timeout-minutes: 45 runs-on: [self-hosted, macos-latest, arm64] env: M1: 1 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: main - uses: ./.github/actions/yarn-install - name: Disable admin-access before start up run: | mkdir -p $HOME/Library/Preferences/rancher-desktop touch $HOME/Library/Preferences/rancher-desktop/settings.json cat < $HOME/Library/Preferences/rancher-desktop/settings.json { "version": 5, "application": { "adminAccess": false "updater": { "enabled": false }, }, "virtualMachine" { "memoryInGB": 6, }, "pathManagementStrategy": "rcfiles" } EOF - name: Run Rancher Desktop in dev run: | yarn dev -- --no-modal-dialogs & sleep 200 $HOME/.rd/bin/rdctl shutdown wait - name: Run e2e Tests continue-on-error: false run: yarn test:e2e - name: Failed tests if: failure() run: mkdir -p ./e2e/reports - name: Upload Artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: failure() with: name: e2etest-artifacts path: ./e2e/reports/* - name: Cleanup test environment run: | #set -x cd $HOME/Library pushd Logs/rancher-desktop for x in *.log ; do echo -n '' > $x done popd rm -fr "Application Support/rancher-desktop" rm -fr Preferences/rancher-desktop rm -fr Caches/rancher-desktop/k3s-versions.json cd $HOME/.rd/bin for x in helm kubectl nerdctl docker ; do if [[ -L $x ]] ; then # && $(readlink $x):]] ; then rm -f $x fi done if: always() - name: End stray processes run: | ps auxww | grep qemu ps auxww | grep rancher | grep -vi -e goland if: always() ================================================ FILE: .github/workflows/package.yaml ================================================ name: Package on: pull_request: paths-ignore: - '.github/actions/spelling/**' - 'docs/**' - '**.md' push: branches: - main - release-* tags: - '*' workflow_dispatch: inputs: sign: type: boolean default: true description: Whether to check signing result defaults: run: shell: bash jobs: check-paths: uses: ./.github/workflows/paths-ignore.yaml with: paths-ignore-globs: | .github/actions/spelling/** docs/** **.md package: needs: check-paths if: needs.check-paths.outputs.should-run == 'true' strategy: matrix: include: - platform: mac arch: x86_64 runs-on: macos-15-intel - platform: mac arch: aarch64 runs-on: macos-latest - platform: win runs-on: windows-latest - platform: linux runs-on: ubuntu-22.04 runs-on: ${{ matrix.runs-on }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # Needed to run `git describe` to get full version info fetch-depth: 0 - uses: ./.github/actions/yarn-install - run: yarn build - run: yarn package - name: Build bats.tar.gz if: matrix.platform == 'linux' run: make -C bats bats.tar.gz - name: Upload bats.tar.gz uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: matrix.platform == 'linux' with: name: bats.tar.gz path: bats/bats.tar.gz if-no-files-found: error - name: Upload mac disk image uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: matrix.platform == 'mac' with: name: Rancher Desktop.${{ matrix.arch }}.dmg path: dist/Rancher Desktop*.dmg if-no-files-found: error - name: Upload mac zip uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: matrix.platform == 'mac' with: name: Rancher Desktop-mac.${{ matrix.arch }}.zip path: dist/Rancher Desktop*.zip if-no-files-found: error - name: Upload Windows installer uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: matrix.platform == 'win' with: name: Rancher Desktop Setup.msi path: dist/Rancher.Desktop*.msi if-no-files-found: error - name: Upload Windows zip uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: matrix.platform == 'win' with: name: Rancher Desktop-win.zip path: dist/Rancher Desktop-*-win.zip if-no-files-found: error - name: Upload Linux zip uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: matrix.platform == 'linux' with: name: Rancher Desktop-linux.zip path: dist/rancher-desktop-*-linux.zip if-no-files-found: error - name: Trigger OBS build if: matrix.platform == 'linux' && github.ref_type == 'branch' && ( startsWith(github.ref_name, 'main') || startsWith(github.ref_name, 'release-') ) run: | if [[ -z $AWS_ACCESS_KEY_ID ]] || [[ -z $OBS_WEBHOOK_TOKEN ]]; then echo "Secrets unavailable, skipping." exit 0 fi # in pull requests GITHUB_REF_NAME is in the form "/merge"; # remove slashes since they aren't valid in filenames no_slash_ref_name="${GITHUB_REF_NAME//\//-}" zip_name="rancher-desktop-linux-${no_slash_ref_name}.zip" # Copy zip file to S3 aws s3 cp \ dist/rancher-desktop-*-linux.zip \ "s3://rancher-desktop-assets-for-obs/$zip_name" # Trigger OBS services for relevant package in dev channel curl -X POST \ -H "Authorization: Token ${OBS_WEBHOOK_TOKEN}" \ "https://build.opensuse.org/trigger/runservice?project=isv:Rancher:dev&package=rancher-desktop-${GITHUB_REF_NAME}" env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: us-east-1 OBS_WEBHOOK_TOKEN: ${{ secrets.OBS_WEBHOOK_TOKEN }} sign-win: name: Test Signing (Windows) needs: package runs-on: windows-2022 if: >- (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release-')) || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign) permissions: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: ./.github/actions/yarn-install - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 name: Download artifact with: name: Rancher Desktop-win.zip - name: Generate test signing certificate shell: powershell run: | $cert = New-SelfSignedCertificate ` -Type Custom ` -Subject "CN=Rancher-Sandbox, C=CA" ` -KeyUsage DigitalSignature ` -CertStoreLocation Cert:\CurrentUser\My ` -FriendlyName "Rancher-Sandbox Code Signing" ` -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") Write-Output $cert Write-Output "CSC_FINGERPRINT=$($cert.Thumbprint)" ` | Out-File -Append -Encoding ASCII "${env:GITHUB_ENV}" timeout-minutes: 1 - name: Sign artifact shell: powershell run: yarn sign (Get-Item "Rancher Desktop*-win.zip") timeout-minutes: 10 - name: Verify installer signature shell: powershell run: | $usedCert = (Get-AuthenticodeSignature -FilePath 'dist\Rancher*Desktop*.msi').SignerCertificate Write-Output $usedCert if ($usedCert.Thumbprint -ne $env:CSC_FINGERPRINT) { Throw "Installer signed with wrong certificate" } timeout-minutes: 1 sign-mac: name: Test Signing (macOS) needs: package strategy: matrix: include: - arch: aarch64 # skip x86_64, we don't need to duplicate the testing for now. runs-on: macos-latest if: >- (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release-')) || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign) permissions: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: ./.github/actions/yarn-install - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 name: Download artifact with: name: Rancher Desktop-mac.${{ matrix.arch }}.zip - name: Generate test signing certificate run: | openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \ -keyform pem -sha256 -days 3650 -nodes -subj \ "/C=CA/CN=RD Test Signing Key" \ -addext keyUsage=critical,digitalSignature \ -addext extendedKeyUsage=critical,codeSigning # Create a custom keychain so we can unlock it properly. security create-keychain -p "" tmp.keychain security default-keychain -d user -s tmp.keychain security unlock-keychain -p "" tmp.keychain security set-keychain-settings -u tmp.keychain # Disable keychain auto-lock security import key.pem -k tmp.keychain -t priv -A security import cert.pem -k tmp.keychain -t cert -A security set-key-partition-list -S apple-tool:,apple:,codesign: -s \ -k "" tmp.keychain # Print out the valid certificates for debugging. security find-identity # Determine the key fingerprint. awk_expr='/)/ { print $2 ; exit }' hash="$(security find-identity | awk "$awk_expr")" echo "CSC_FINGERPRINT=${hash}" >> "$GITHUB_ENV" timeout-minutes: 1 - name: Flag build for M1 if: matrix.arch == 'aarch64' run: echo "M1=1" >> "${GITHUB_ENV}" - name: Sign artifact run: | for zip in Rancher\ Desktop-*mac*.zip; do echo "::group::Signing ${zip}" yarn sign --skip-notarize --skip-constraints "${zip}" echo "::endgroup::" done timeout-minutes: 15 - name: Verify signature run: | codesign --verify --deep --strict --verbose=2 dist/*.dmg codesign --verify --deep --strict --verbose=2 dist/*.zip timeout-minutes: 5 ================================================ FILE: .github/workflows/paths-ignore.yaml ================================================ # This is a reusable workflow to determine if the current change requires an E2E # run. This is required because using [paths-ignored] directly means the whole # workflow is skipped, but that means that it doesn't count as having run a # required workflow. # Usage: # jobs: # check-paths: # uses: ./.github/workflows/actions/paths-ignore.yaml # do_thing: # if: jobs.check-paths.outputs.should-run == 'true' # # Unfortunately, a string comparison is required. name: Check for ignored paths on: workflow_call: inputs: paths-ignore-globs: description: > Paths to ignore. Should glob patterns (as a git pathspec glob), one per line. See https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-glob type: string default: | .github/actions/spelling/** bats/** docs/** **.md outputs: should-run: description: Whether other steps should run. value: ${{ jobs.check.outputs.should-run }} permissions: contents: read jobs: check: runs-on: ubuntu-latest steps: - name: Set baseline result run: echo "SHOULD_RUN=true" >> "$GITHUB_ENV" - name: Determine paths to ignore if: github.event_name == 'pull_request' run: | PATHS_IGNORE="PATHS_IGNORE=" while read -r line; do if [[ -n $line ]]; then PATHS_IGNORE="${PATHS_IGNORE} :!/${line}" fi done <<< "$INPUT" echo "$PATHS_IGNORE" echo "$PATHS_IGNORE" >> "$GITHUB_ENV" env: INPUT: ${{ inputs.paths-ignore-globs }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: github.event_name == 'pull_request' with: fetch-depth: 0 persist-credentials: false - name: Check for differences if: github.event_name == 'pull_request' run: | MERGE_BASE=$(git merge-base $BASE $HEAD) diff="$(git diff --name-only $MERGE_BASE $HEAD -- $PATHS_IGNORE)" if [[ -z "$diff" ]]; then echo "No modified files found." echo "SHOULD_RUN=false" >> "$GITHUB_ENV" else printf "Modified files:\n%s\n" "$diff" fi env: BASE: ${{ github.event.pull_request.base.sha }} HEAD: ${{ github.event.pull_request.head.sha }} - name: Set final output id: result run: echo "should-run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" outputs: should-run: ${{ steps.result.outputs.should-run }} ================================================ FILE: .github/workflows/rddepman.yaml ================================================ name: Update external dependencies on: schedule: - cron: '23 8 * * *' workflow_dispatch: {} permissions: contents: write pull-requests: write jobs: check-for-token: outputs: has-token: ${{ steps.calc.outputs.HAS_SECRET }} runs-on: ubuntu-latest steps: - id: calc run: echo "HAS_SECRET=${HAS_SECRET}" >> "${GITHUB_OUTPUT}" env: HAS_SECRET: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW != '' }} check-update-versions: needs: check-for-token if: needs.check-for-token.outputs.has-token == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - uses: ./.github/actions/yarn-install - run: yarn rddepman env: GITHUB_TOKEN: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW }} ================================================ FILE: .github/workflows/rdx-host-api-tests.yaml ================================================ # This workflow builds the Rancher Desktop Extensions Host APIs testing image # and publishes it. name: RDX Host APIs Testing image on: push: branches: [ main ] paths: [ 'bats/tests/extensions/testdata/**' ] workflow_dispatch: {} permissions: packages: write jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 id: meta with: images: | ghcr.io/${{ github.repository }}/rdx-host-api-test tags: type=raw,value=latest,enable={{ is_default_branch }} - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: build-args: variant=host-apis context: bats/tests/extensions/testdata platforms: | linux/amd64 linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} ================================================ FILE: .github/workflows/release-merge-to-main.yaml ================================================ name: "Release: Merge to main" on: release: types: - created - published - released concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: {} jobs: check-for-token: outputs: has-token: ${{ steps.calc.outputs.HAS_SECRET }} runs-on: ubuntu-latest steps: - id: calc run: echo "HAS_SECRET=${HAS_SECRET}" >> "${GITHUB_OUTPUT}" env: HAS_SECRET: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW != '' }} create-pr: needs: check-for-token if: needs.check-for-token.outputs.has-token == 'true' runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/yarn-install - run: node scripts/ts-wrapper.js scripts/release-merge-to-main.ts env: GITHUB_WRITE_TOKEN: ${{ github.token }} GITHUB_PR_TOKEN: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW }} ================================================ FILE: .github/workflows/scorecard.yml ================================================ name: Scorecard supply-chain security on: # For Branch-Protection check. Only the default branch is supported. See # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection branch_protection_rule: # To guarantee Maintained check is occasionally updated. See # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained schedule: - cron: '23 13 * * 4' push: branches: - master workflow_dispatch: # Declare default permissions as read only. permissions: read-all jobs: analysis: name: Scorecard analysis runs-on: ubuntu-latest permissions: # Needed to upload the results to code-scanning dashboard. security-events: write # Needed to publish results and get a badge (see publish_results below). id-token: write steps: - name: "Checkout code" uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Run analysis" uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: # - you want to enable the Branch-Protection check on a *public* repository, or # - you are installing Scorecard on a *private* repository # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. # repo_token: ${{ secrets.SCORECARD_TOKEN }} # Public repositories: # - Publish results to OpenSSF REST API for easy access by consumers # - Allows the repository to include the Scorecard badge. # - See https://github.com/ossf/scorecard-action#publishing-results. publish_results: true # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v3.pre.node20 with: name: SARIF file path: results.sarif retention-days: 5 # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 with: sarif_file: results.sarif ================================================ FILE: .github/workflows/screenshot.yaml ================================================ name: Screenshots on: workflow_dispatch: inputs: mock_version: description: Mock Version type: string required: true default: '1.0.0' jobs: screenshot: name: Take screenshot concurrency: group: "${{ github.workflow_ref }} (${{ matrix.platform }})" cancel-in-progress: true strategy: fail-fast: false matrix: include: - platform: mac # Use an x86_64 platform because arm64 runners don't have nested # virtualization available. runs-on: macos-15-intel - platform: win runs-on: windows-latest - platform: linux runs-on: ubuntu-latest runs-on: ${{ matrix.runs-on }} steps: - name: "macOS: Install GetWindowID" if: runner.os == 'macOS' run: | brew update brew install smokris/getwindowid/getwindowid - name: "Linux: Install Tools" if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install graphicsmagick x11-utils mutter # spellcheck-ignore-line - name: "macOS: Set startup command" if: runner.os == 'macOS' run: echo "EXEC_COMMAND=$EXEC_COMMAND" >> "$GITHUB_ENV" env: EXEC_COMMAND: exec - name: "Linux: Set startup command" if: runner.os == 'Linux' run: | # Write a wrapper script to start mutter (so we get window decorations). echo '#!/bin/sh' > /usr/local/bin/exec-command echo 'mutter --replace --sm-disable --x11 &>/dev/null &' >> /usr/local/bin/exec-command echo 'exec "$@"' >> /usr/local/bin/exec-command chmod a+x /usr/local/bin/exec-command echo "EXEC_COMMAND=$EXEC_COMMAND /usr/local/bin/exec-command" >> "$GITHUB_ENV" env: EXEC_COMMAND: >- exec xvfb-run --auto-servernum --server-args='-screen 0 1280x960x24' - name: "Windows: Set startup command" if: runner.os == 'Windows' shell: bash run: echo "EXEC_COMMAND=$EXEC_COMMAND" >> "$GITHUB_ENV" env: EXEC_COMMAND: # On Windows, we don't need any commands. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: ./.github/actions/yarn-install - uses: ./.github/actions/setup-environment - name: Override version if: inputs.mock_version run: echo "RD_MOCK_VERSION=${{ inputs.mock_version }}" >> "${GITHUB_ENV}" shell: bash - run: ${{ env.EXEC_COMMAND }} yarn screenshots env: EXEC_COMMAND: ${{ env.EXEC_COMMAND }} RD_ENV_SCREENSHOT_SLEEP: 5000 RD_LOGS_DIR: logs - name: Upload screenshots uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: screenshots-${{ matrix.platform }}.zip path: screenshots/output/ if-no-files-found: error - name: Upload logs uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: always() with: name: logs-${{ matrix.platform }}.zip path: | logs/ e2e/reports/ screenshots/output/ package: name: Package screenshots for docs needs: screenshot concurrency: group: "${{ github.workflow_ref }} (package)" cancel-in-progress: true runs-on: ubuntu-latest steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: screenshots-*.zip merge-multiple: true path: ${{ github.workspace }}/in - name: Rename images run: | while IFS= read -d $'\0' -r line; do IFS=/ read -r in platform scheme window name <<<"$line" if [[ "$scheme" != "light" ]]; then continue fi case "$platform" in darwin) platform=macOS;; linux) platform=Linux;; win32) platform=Windows;; esac if [[ "$window" == "main" ]]; then window="ui-main" fi if [[ $name =~ ^[0-9]+_ ]]; then name="${name#*_}" fi if [[ name == "intro" ]]; then continue fi out="out/${window}/${platform}_${name}" mkdir -p "$(dirname "$out")" cp "$line" "$out" echo "$out" done < <(find in -name '*.png' -print0) - name: Generate introduction image run: | # The intro image consists of the mac image on the left and the Windows # image on the right, each showing Kubernetes settings. sudo DEBIAN_FRONTEND=noninteractive apt-get install graphicsmagick # spellcheck-ignore-line mkdir -p out/getting-started gm convert in/darwin/light/main/*_intro.png in/win32/light/main/*_intro.png +append out/getting-started/introduction_preferences_tabKubernetes.png - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: screenshots.zip path: out if-no-files-found: error overwrite: true ================================================ FILE: .github/workflows/smoke-test/install-from-repo.sh ================================================ #!/usr/bin/env bash # This script is expected to run as root and install Rancher Desktop from the # repository obs://isv:Rancher:dev # Expected environment variables: # RD_VERSION # Rancher Desktop version; either major.minor (`1.20`) or the tag (`v1.20.0`). set -o errexit -o nounset # shellcheck disable=2329 # The function is invoked dynamically install_linux_debian() { local keyLocation=/usr/share version if [[ -d /etc/apt/keyrings ]]; then keyLocation=/etc/apt fi apt-get update apt-get install -y gnupg curl -s https://download.opensuse.org/repositories/isv:/Rancher:/dev/deb/Release.key \ | gpg --dearmor \ > "${keyLocation}/keyrings/isv-rancher-dev-archive-keyring.gpg" echo "deb [signed-by=${keyLocation}/keyrings/isv-rancher-dev-archive-keyring.gpg] https://download.opensuse.org/repositories/isv:/Rancher:/dev/deb/ ./"\ > /etc/apt/sources.list.d/isv-rancher-dev.list apt-get update version=$(apt-cache show --quiet rancher-desktop \ | awk -F': ' "/^Version: 0\.release${RD_VERSION//./\\.}\./ { print \$2 }") if [[ -z "${version}" ]]; then echo "Could not find any versions of rancher-desktop" >&2 exit 1 fi apt-get install -y "rancher-desktop=${version}" } # shellcheck disable=2329 # The function is invoked dynamically install_linux_opensuse() { zypper --non-interactive addrepo https://download.opensuse.org/repositories/isv:/Rancher:/dev/rpm/isv:Rancher:dev.repo zypper --non-interactive --gpg-auto-import-keys install libxml2-tools local version version=$(zypper --xmlout --non-interactive search --details --match-exact rancher-desktop \ | xmllint --xpath "string(//solvable[@kind='package']/@edition[contains(., '0.release${RD_VERSION}.')])" -) zypper --non-interactive install "rancher-desktop=${version}" } # shellcheck disable=2329 # The function is invoked dynamically install_linux_fedora() { dnf config-manager addrepo --from-repofile=https://download.opensuse.org/repositories/isv:/Rancher:/dev/fedora/isv:Rancher:dev.repo local version version=$(dnf --quiet info --showduplicates rancher-desktop.x86_64 \ | awk -F: "\$1 ~ /Version/ && \$2 ~ /0\.release${RD_VERSION//./\\.}/ { print \$2 }" \ | tr -d '[:space:]') dnf --assumeyes install "rancher-desktop-${version}" } main() { RD_VERSION=$(grep --only-matching '\([0-9]\+\.[0-9]\+\)' <<< "$RD_VERSION") source /etc/os-release for id in ${ID:-} ${ID_LIKE:-}; do if [[ "$(type -t "install_linux_$id")" == function ]]; then eval "install_linux_$id" exit 0 fi done printf "Could not find supported distribution in %s\n" "${ID:-} ${ID_LIKE:-}" >&2 exit 1 } main ================================================ FILE: .github/workflows/smoke-test/smoke-test.sh ================================================ #!/usr/bin/env bash # This script is expected to run from CI, and does a final smoke test. # There should be an installer in the current directory (or, in the case of # Linux, a zip file), along with its accompanying sha512sum file. # On Windows, build/signing-config-win.yaml is also required. # Environment variables as inputs: # RD_SKIP_INSTALL (Linux) # Skip installing Rancher Desktop, and assume it was installed from the repo. # Required tools: # - jq # - yq (Windows only) # Note that, on Windows, this is run via msys bash (installed wit git). set -o errexit -o nounset shopt -s nullglob export MSYS2_ARG_CONV_EXCL='*' RDCTL= # Path to rdctl APPIMAGE_PID= # PID of AppImage process; not used if not using AppImage. # All commands in the cleanups array will be run on exit. They must be plain # strings that will be passed to eval cleanups=() # Run the cleanups. do_cleanup() { # In case the array has holes (it shouldn't), make an array of indices. local indices=("${!cleanups[@]}") local i for (( i=${#indices[@]} - 1; i >= 0; i-- )); do # shellcheck disable=2086 # We expect to glob and word split eval ${cleanups[$i]} done } trap do_cleanup EXIT # Locate the archive, check its checksum, and echo the file name. get_archive() { local checksum archiveName if [[ -n "${RD_SKIP_INSTALL:-}" ]]; then echo "Skipping getting archive." >&2 echo "no-archive-used" return fi for checksum in *.sha512sum; do archiveName=${checksum%.sha512sum} if command -v sha512sum &>/dev/null; then sha512sum --check --quiet --strict "$checksum" else shasum --check --quiet --algorithm 512 "$checksum" fi grep --quiet "$archiveName" "$checksum" readlink -f "$archiveName" return done echo "Failed to find archive." >&2 exit 1 } # Return the current platform; one of "darwin", "linux", "win32" get_platform() { case "$(uname -s)" in Darwin) echo "darwin";; Linux) echo "linux";; MINGW*) echo "win32";; *) printf "Unsupported platform %s\n" "$(uname -s)" >&2 exit 1;; esac } # Assume the first argument given is a path to the Rancher Desktop .dmg disk # image; install it, and set the global variable RDCTL to the path of the rdctl # executable. install_darwin() { local archiveName=$1 local mountpoint mountpoint=$(mktemp -d -t rd-dmg-) cleanups+=("rm -rf '$mountpoint'") local srcApp="${mountpoint}/Rancher Desktop.app" local destApp="/Applications/Rancher Desktop.app" codesign --verify --deep --strict --verbose=2 --check-notarization "$archiveName" hdiutil attach "$archiveName" -mountpoint "$mountpoint" cleanups+=("hdiutil detach '$mountpoint'") codesign --verify --deep --strict --verbose=2 --check-notarization "$srcApp" mkdir -p "$destApp" cleanups+=("rm -rf '$destApp'") cp -a "$srcApp" "$(dirname "$destApp")" xattr -d -r -s -v com.apple.quarantine "$destApp" # Check that the image is compressed local compressionRatio compressionRatio="$(hdiutil imageinfo -plist "$archiveName" \ | plutil -convert json -o - - \ | jq '.["Size Information"]["Compressed Ratio"]')" if jq --exit-status '. > 0.9' <<<"$compressionRatio"; then printf "Archive %s appears to be uncompressed; compression ratio is %s\n" \ "$archiveName" "$compressionRatio" >&2 exit 1 fi if [[ "$(uname -m)" =~ arm ]]; then # For macOS, currently only x86_64 runners support nested virtualization # https://github.com/actions/runner-images/issues/9460 # Abort the script (gracefully) instead of trying to run RD. echo "Skipping actually running on Rancher Desktop because arm64 runners do not have nested virtualization" >&2 exit 0 fi RDCTL="$destApp/Contents/Resources/resources/darwin/bin/rdctl" } # Assume the first argument given is a path to the Rancher Desktop zip file; # install it, and set the global variable RDCTL to the path of the rdctl # executable. If the archive is an AppImage file instead, then this function # instead sets APPIMAGE_PID. install_linux() { if [[ $(id --user) -eq 0 ]]; then echo "This script should not be run as root" >&2 exit 1 fi if [[ -z "${RD_SKIP_INSTALL:-}" ]]; then local archiveName=$1 if [[ "$archiveName" =~ .*\.AppImage$ ]]; then sudo chmod a+x "$archiveName" "$archiveName" \ --no-sandbox --enable-logging=stderr --v=1 \ --no-modal-dialogs --kubernetes.enabled \ --application.updater.enabled=false& APPIMAGE_PID=$! return else sudo mkdir -p /opt/rancher-desktop sudo unzip -d /opt/rancher-desktop "$archiveName" sudo chmod 4755 /opt/rancher-desktop/chrome-sandbox fi fi RDCTL="/opt/rancher-desktop/resources/resources/linux/bin/rdctl" } # Helper function on Windows to verify the signature of a file (provided as the # first argument). win32_verify() { local path path="$(cygpath --windows "$1")" # When running GitHub actions, using `powershell.exe` here causes issues # with loading the `Microsoft.PowerShell.Security` module; using `pwsh.exe` # seems to be fine. This is probably because the default shell is pwsh, and # the environment has paths to the PowerShell 7 version of the module, so it # tries to load that instead of the version appropriate for PowerShell.exe. local pwsh=(pwsh.exe -NoLogo -NoProfile -NonInteractive -Command) local stdout stdout=$("${pwsh[@]}" "\$(Get-AuthenticodeSignature '$path').Status") if [[ "$stdout" != "Valid" ]]; then printf "%s is not correctly signed:\n" "$path" "${pwsh[@]}" "Get-AuthenticodeSignature '$path' | Format-List" exit 1 fi } # Assume the first argument given is a path to the Rancher Desktop installer; # install it, and set the global variable RDCTL to the path of the rdctl # executable. install_win32() { local archiveName=$1 win32_verify "$archiveName" mkdir -p "$(cygpath --unix "${RD_LOGS_DIR}")" msiexec.exe '/lv*x' "${RD_LOGS_DIR}\\install.log" \ /i "$(cygpath --windows "$archiveName")" /passive ALLUSERS=1 # msiexec returns immediately and runs in the background; wait for that # process to exit before continuing. local deadline completed deadline=$(( $(date +%s) + 10 * 60 )) while [[ $(date +%s) -lt $deadline ]]; do if tasklist.exe /FI "ImageName eq msiexec.exe" | grep msiexec; then printf "Waiting for msiexec to finish: %s/%s\n" "$(date)" "$(date --date="@$deadline")" sleep 10 else completed=true break fi done if [[ -z "${completed:-}" ]]; then echo "msiexec took too long to finish, aborting" >&2 exit 1 fi local installDirectory installDirectory=$(cygpath --unix 'C:\Program Files\Rancher Desktop') local rdctl="$installDirectory/resources/resources/win32/bin/rdctl.exe" local -a keys mapfile -t keys < <(yq.exe 'keys | .[]' < build/signing-config-win.yaml) local key for key in "${keys[@]}"; do local expr='.[env(key)][] | select(. != "!*")' local -a values mapfile -t values < <(key=$key yq.exe "$expr" < build/signing-config-win.yaml) for value in "${values[@]}"; do if [[ "$value" == "wix-custom-action.dll" ]]; then # This file is not installed continue fi win32_verify "$installDirectory/$key/$value" done done # Verify that rdctl exists win32_verify "$rdctl" RDCTL=$rdctl } # Wait for the backend to be alive. $RDCTL must be set (from the install_* # functions). If $APPIMAGE_PID is set, assume we're running AppImage instead. wait_for_backend() { local deadline state deadline_date platform rd_pid deadline=$(( $(date +%s) + 10 * 60 )) deadline_date=$({ date --date="@$deadline" || date -j -f %s "$deadline"; } 2>/dev/null) platform=$(get_platform) while [[ $(date +%s) -lt $deadline ]]; do if [[ -n "${APPIMAGE_PID:-}" ]] && [[ -z "${RDCTL:-}" ]]; then rd_pid=$(pidof --separator $'\n' rancher-desktop | sort -n | head -n 1 || echo missing) if [[ -e /proc/$rd_pid/exe ]]; then RDCTL=$(dirname "$(readlink /proc/$rd_pid/exe)")/resources/resources/linux/bin/rdctl continue fi state=NOT_RUNNING elif [[ $platform == linux ]] && [[ ! -e $HOME/.local/share/rancher-desktop/rd-engine.json ]]; then state=NO_SERVER_CONFIG else state=$("$RDCTL" api /v1/backend_state || echo '{"vmState": "NO_RESPONSE"}') state=$(jq --raw-output .vmState <<< "$state") fi case "$state" in ERROR) echo "Backend reached error state." >&2 exit 1 ;; STARTED|DISABLED) return ;; *) printf "Backend state: %s\n" "$state";; esac # if we get here, either we failed to get state or it's starting. printf "Waiting for backend: (%s) %s/%s\n" "$state" "$(date)" "$deadline_date" sleep 10 done echo "Timed out waiting for backend to stabilize." >&2 printf "Current time: %s\n" "$(date)" >&2 printf "Deadline: %s\n" "$deadline_date" >&2 exit 1 } main() { local archive platform platform=$(get_platform) archive=$(get_archive) eval "install_${platform}" "$archive" if [[ -z "${APPIMAGE_PID:-}" ]]; then "$RDCTL" start --no-modal-dialogs \ --kubernetes.enabled --application.updater.enabled=false cleanups+=("'$RDCTL' shutdown") fi wait_for_backend echo "Smoke test passed." } main ================================================ FILE: .github/workflows/smoke-test.yaml ================================================ # This workflow downloads artifacts from a (by default, draft) release and runs # a short smoke test where the application is installed and run and immediately # shut down. # Since we need contents-write permissions to look at draft releases, we # actually download the artifacts in a smaller job, then upload them into the # run and download it _again_ in the second (per-platform) job where no # permissions are required. name: Release smoke test permissions: {} on: workflow_dispatch: inputs: tag: description: > Download artifacts from release with this tag, rather than picking the first draft release. type: string jobs: download-artifacts: name: Find release runs-on: ubuntu-latest permissions: contents: write # Needed to list draft releases env: RELEASE_TAG: ${{ inputs.tag }} outputs: release-tag: ${{ steps.set-release-tag.outputs.release-tag }} steps: - name: Find release if: inputs.tag == '' run: >- set -o xtrace; printf "RELEASE_TAG=%s\n" >>"$GITHUB_ENV" "$(gh api repos/${{ github.repository }}/releases --jq 'map(select(.draft))[0].tag_name')" env: GH_TOKEN: ${{ github.token }} - id: set-release-tag run: >- printf "release-tag=%s\n" "$RELEASE_TAG" >> "$GITHUB_OUTPUT" - name: Download artifacts run: | if [[ -z "$RELEASE_TAG" ]]; then echo "Failed to find release tag" >&2 exit 1 fi gh release download "$RELEASE_TAG" \ --repo ${{ github.repository }} \ --pattern '*.dmg' \ --pattern '*.dmg.sha512sum' \ --pattern '*.msi' \ --pattern '*.msi.sha512sum' \ --pattern 'rancher-desktop-linux-*.zip' \ --pattern 'rancher-desktop-linux-*.zip.sha512sum' env: GH_TOKEN: ${{ github.token }} - name: Download AppImage run: | branch=$(cut -d. -f1,2 <<< "${RELEASE_TAG#v}") read -r artifact_name < <( curl "${OBS_DOWNLOAD_URL}?jsontable" \ | jq --raw-output ".data[].name | select(endswith(\".AppImage\")) | select(contains(\".release${branch}.\"))" ) curl -L -o rancher-desktop.AppImage "${OBS_DOWNLOAD_URL}${artifact_name}" # The AppImage does not have a checksum; make one up. sha512sum rancher-desktop.AppImage > rancher-desktop.AppImage.sha512sum chmod a+x rancher-desktop.AppImage env: OBS_DOWNLOAD_URL: https://download.opensuse.org/download/repositories/isv:/Rancher:/dev/AppImage/ - name: Upload macOS aarch-64 artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: application-macos-aarch64.zip if-no-files-found: error path: | *.aarch64.dmg *.aarch64.dmg.sha512sum - name: Upload macOS x86_64 artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: application-macos-x86_64.zip if-no-files-found: error path: | *.x86_64.dmg *.x86_64.dmg.sha512sum - name: Upload Windows artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: application-win32.zip if-no-files-found: error path: | *.msi *.msi.sha512sum - name: Upload Linux artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: application-linux.zip if-no-files-found: error path: | rancher-desktop-linux-*.zip rancher-desktop-linux-*.zip.sha512sum - name: Upload Linux AppImage uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: application-linux.AppImage if-no-files-found: error path: | rancher-desktop.AppImage rancher-desktop.AppImage.sha512sum smoke-test: name: Smoke test needs: download-artifacts strategy: fail-fast: false matrix: include: - { platform: macos-aarch64, runs-on: macos-14 } - { platform: macos-x86_64, runs-on: macos-15-intel } - { platform: win32, runs-on: windows-latest } - { platform: linux, runs-on: ubuntu-latest } runs-on: ${{ matrix.runs-on }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up environment uses: ./.github/actions/setup-environment - name: "Linux: Set startup command" if: runner.os == 'Linux' run: echo "EXEC_COMMAND=$EXEC_COMMAND" >> "$GITHUB_ENV" env: EXEC_COMMAND: >- exec xvfb-run --auto-servernum --server-args='-screen 0 1280x960x24' - name: Set log directory shell: bash # Use node here to do path manipulation to get correct Windows paths. run: >- node --eval='console.log("RD_LOGS_DIR=" + require("path").join(process.cwd(), "logs"));' >> "$GITHUB_ENV" - name: Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: application-${{ matrix.platform }}.zip - run: ${{ env.EXEC_COMMAND }} .github/workflows/smoke-test/smoke-test.sh shell: bash - name: Upload logs uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: always() with: name: logs-${{ matrix.platform }}.zip path: ${{ github.workspace }}/logs if-no-files-found: warn repository-smoke-test: name: Smoke test repository needs: download-artifacts strategy: fail-fast: false matrix: include: - { id: opensuse-tumbleweed, image: "registry.opensuse.org/opensuse/tumbleweed:latest"} - { id: opensuse-leap, image: "registry.opensuse.org/opensuse/leap:latest" } - { id: debian, image: "debian:latest" } - { id: fedora, image: "fedora:latest" } runs-on: ubuntu-latest container: image: ${{ matrix.image }} options: --privileged steps: - name: Install basic tools if: contains(matrix.id, 'opensuse') run: >- zypper --non-interactive install git-core tar - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up user run: | useradd --create-home --user-group ci-user export LOGS_DIR=$PWD/logs export RD_LOGS_DIR=$LOGS_DIR/rd echo "LOGS_DIR=$LOGS_DIR" >> "$GITHUB_ENV" echo "RD_LOGS_DIR=$RD_LOGS_DIR" >> "$GITHUB_ENV" mkdir -p $RD_LOGS_DIR chown --recursive ci-user "$LOGS_DIR" - uses: ./.github/actions/setup-environment - name: Install Rancher Desktop from package if: runner.os == 'Linux' run: .github/workflows/smoke-test/install-from-repo.sh env: RD_VERSION: ${{ needs.download-artifacts.outputs.release-tag }} - name: "openSUSE Workaround for #9145" if: contains(matrix.id, 'opensuse') run: >- zypper --non-interactive install qemu-img qemu-hw-display-virtio-gpu - name: Run smoke test shell: bash run: | inner_command=( xvfb-run --auto-servernum --server-args='-screen 0 1280x960x24' $PWD/.github/workflows/smoke-test/smoke-test.sh ) sudo --user=ci-user --login --set-home --non-interactive \ /usr/bin/env --chdir=$PWD \ RD_DEBUG_ENABLED=1 RD_TEST=smoke RD_SKIP_INSTALL=true RD_LOGS_DIR=$RD_LOGS_DIR \ script \ --log-out $LOGS_DIR/repo-${{ matrix.id }}.log \ --return --command "${inner_command[*]@Q}" - name: Take screenshot if: failure() continue-on-error: true shell: >- sudo --user=ci-user --login --set-home --non-interactive bash --noprofile --norc -eo pipefail {0} run: | set -o xtrace -o errexit PID=$(pidof smoke-test.sh || echo missing) if [[ ! -r /proc/$PID/environ ]]; then echo "Rancher Desktop is not running" >&2 exit 0 fi export $(gawk 'BEGIN { RS="\0"; FS="=" } ($1 == "DISPLAY" || $1 == "XAUTHORITY") { print }' \ < /proc/$PID/environ) env export MAGICK_DEBUG=All # spellcheck-ignore-line gm import -window root -verbose $LOGS_DIR/screenshot-${{ matrix.id }}.png - name: Upload logs uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: always() with: name: logs-repo-${{ matrix.id }}.zip path: ${{ github.workspace }}/logs if-no-files-found: warn appimage-smoke-test: name: Smoke test AppImage needs: download-artifacts strategy: fail-fast: false matrix: include: - { id: opensuse, image: "registry.opensuse.org/opensuse/tumbleweed:latest" } - { id: rocky, image: "rockylinux/rockylinux:9" } runs-on: ubuntu-latest container: image: ${{ matrix.image }} options: --privileged steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up user run: | useradd --create-home --user-group ci-user export LOGS_DIR=$PWD/logs export RD_LOGS_DIR=$LOGS_DIR/rd echo "LOGS_DIR=$LOGS_DIR" >> "$GITHUB_ENV" echo "RD_LOGS_DIR=$RD_LOGS_DIR" >> "$GITHUB_ENV" mkdir -p $RD_LOGS_DIR chown --recursive ci-user "$LOGS_DIR" - uses: ./.github/actions/setup-environment with: user: ci-user - name: Download AppImage uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: application-linux.AppImage - name: Run smoke test run: | inner_command=( xvfb-run --auto-servernum --server-args='-screen 0 1280x960x24' $PWD/.github/workflows/smoke-test/smoke-test.sh ) sudo --user=ci-user --login --set-home --non-interactive \ /usr/bin/env --chdir=$PWD \ RD_DEBUG_ENABLED=1 RD_TEST=smoke RD_LOGS_DIR=$RD_LOGS_DIR \ script \ --log-out $LOGS_DIR/appimage-${{ matrix.id }}.log \ --return --command "${inner_command[*]@Q}" \ - name: Take screenshot if: failure() continue-on-error: true shell: >- sudo --user=ci-user --login --set-home --non-interactive bash --noprofile --norc -eo pipefail {0} run: | set -o xtrace -o errexit PID=$(pidof rancher-desktop.AppImage || echo missing) if [[ ! -r /proc/$PID/environ ]]; then echo "Rancher Desktop is not running" >&2 exit 0 fi export $(gawk 'BEGIN { RS="\0"; FS="=" } ($1 == "DISPLAY" || $1 == "XAUTHORITY") { print }' \ < /proc/$PID/environ) env export MAGICK_DEBUG=All # spellcheck-ignore-line gm import -window root -verbose $LOGS_DIR/screenshot-${{ matrix.id }}.png - name: Upload logs uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: always() with: name: logs-appimage-${{ matrix.id }}.zip path: ${{ github.workspace }}/logs if-no-files-found: warn ================================================ FILE: .github/workflows/spelling.yml ================================================ name: Check Spelling # Comment management is handled through a secondary job, for details see: # https://github.com/check-spelling/check-spelling/wiki/Feature%3A-Restricted-Permissions # # `jobs.comment-push` runs when a push is made to a repository and the `jobs.spelling` job needs to make a comment # (in odd cases, it might actually run just to collapse a comment, but that's fairly rare) # it needs `contents: write` in order to add a comment. # # `jobs.comment-pr` runs when a pull_request is made to a repository and the `jobs.spelling` job needs to make a comment # or collapse a comment (in the case where it had previously made a comment and now no longer needs to show a comment) # it needs `pull-requests: write` in order to manipulate those comments. # Updating pull request branches is managed via comment handling. # For details, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-expect-list # # These elements work together to make it happen: # # `on.issue_comment` # This event listens to comments by users asking to update the metadata. # # `jobs.update` # This job runs in response to an issue_comment and will push a new commit # to update the spelling metadata. # # `with.experimental_apply_changes_via_bot` # Tells the action to support and generate messages that enable it # to make a commit to update the spelling metadata. # # `with.ssh_key` # In order to trigger workflows when the commit is made, you can provide a # secret (typically, a write-enabled github deploy key). # # For background, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-with-deploy-key # SARIF reporting # # Access to SARIF reports is generally restricted (by GitHub) to members of the repository. # # Requires enabling `security-events: write` # and configuring the action with `use_sarif: 1` # # For information on the feature, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-SARIF-output # Minimal workflow structure: # # on: # push: # ... # jobs: # # you only want the spelling job, all others should be omitted # spelling: # # remove `security-events: write` and `use_sarif: 1` # # remove `experimental_apply_changes_via_bot: 1` # ... otherwise, adjust the `with:` as you wish # on.pull_request(_target).edited is only needed for with.check_commit_messages: title | description on: push: branches: - "**" tags-ignore: - "**" pull_request: branches: - "**" types: - 'opened' - 'reopened' - 'synchronize' permissions: {} jobs: spelling: name: Check Spelling permissions: contents: read pull-requests: read actions: read security-events: write # To be able to write SARIF events runs-on: ubuntu-latest if: ${{ contains(github.event_name, 'pull_request') || github.event_name == 'push' }} concurrency: group: spelling-${{ github.event.pull_request.number || github.ref }} # note: If you use only_check_changed_files, you do not want cancel-in-progress cancel-in-progress: true env: UPLOAD_SARIF_LIMITED: '' # Set by `yarn lint:spelling`. steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # We don't actually need the full `yarn install`; we just do enough to set # up `yarn` to get `yarn lint:spelling` to work. - name: Drop all dependencies run: | yq --inplace '.dependencies = {} | .devDependencies = {}' package.json rm -f yarn.lock - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: package.json - run: corepack enable yarn - run: yarn install --no-immutable --mode=skip-build - run: sudo apt-get install cpanminus - name: Check Spelling run: yarn lint:spelling env: GITHUB_TOKEN: ${{ github.token }} # Needed to generate SARIF reports. RD_LINT_SPELLING: 1 - name: Upload SARIF report # Use the limited report since if we have more than 25k errors nobody is # going read through it all anyway. if: always() && env.UPLOAD_SARIF_LIMITED != '' continue-on-error: true uses: github/codeql-action/upload-sarif@v4 with: category: check-spelling sarif_file: ${{ env.UPLOAD_SARIF_LIMITED }} ================================================ FILE: .github/workflows/test.yaml ================================================ name: Test on: push: {} pull_request: {} permissions: {} jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: ./.github/actions/yarn-install - run: yarn build - run: yarn lint:nofix - name: Install shfmt run: go install mvdan.cc/sh/v3/cmd/shfmt@latest - run: make -C bats lint - run: yarn test lint: strategy: matrix: # We run the Linux lint in the `test` flow, no need to repeat it. runs-on: [windows-latest, macos-latest] runs-on: ${{ matrix.runs-on }} steps: - if: runner.os == 'Windows' name: Configure git to use Unix line endings run: | git config --global core.autocrlf false git config --global core.eol lf - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: ./.github/actions/yarn-install - run: yarn license-check - run: ./scripts/go-license-check.sh shell: bash - run: yarn lint:nofix ================================================ FILE: .github/workflows/ucmonitor.yaml ================================================ name: Check for unreleased changes on: schedule: - cron: '48 8 * * *' workflow_dispatch: {} permissions: issues: write jobs: check-for-token: outputs: has-token: ${{ steps.calc.outputs.HAS_SECRET }} runs-on: ubuntu-latest steps: - id: calc run: echo "HAS_SECRET=${HAS_SECRET}" >> "${GITHUB_OUTPUT}" env: HAS_SECRET: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW != '' }} check-unreleased-changes: needs: check-for-token if: needs.check-for-token.outputs.has-token == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - uses: ./.github/actions/yarn-install - run: yarn ucmonitor env: GITHUB_TOKEN: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW }} ================================================ FILE: .github/workflows/upgrade-generate.yaml ================================================ name: Generate Upgrade Test Data on: workflow_dispatch: {} permissions: contents: read jobs: build: strategy: matrix: include: - platform: mac arch: x86_64 runs-on: macos-15-intel - platform: mac arch: aarch64 runs-on: macos-latest - platform: win runs-on: windows-latest runs-on: ${{ matrix.runs-on }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # Needed to run `git describe` to get full version info fetch-depth: 0 - uses: ./.github/actions/yarn-install - run: yarn build - run: yarn package - name: Upload Windows installer if: runner.os == 'Windows' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: Rancher Desktop Setup.msi path: dist/Rancher.Desktop*.msi if-no-files-found: error - if: runner.os == 'Windows' run: cat dist/electron-builder.yaml - name: Upload Windows build information if: runner.os == 'Windows' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: build-info.yml path: dist/electron-builder.yaml if-no-files-found: error - name: Upload macOS archive uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: matrix.platform == 'mac' with: name: Rancher Desktop-mac.${{ matrix.arch }}.zip path: dist/Rancher Desktop*.zip if-no-files-found: error release: runs-on: ubuntu-latest needs: build permissions: contents: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: ./.github/actions/yarn-install - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: gh-pages path: pages persist-credentials: true - name: Download installer (msi) id: msi uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: Rancher Desktop Setup.msi path: RD_SETUP_MSI - name: Download mac x86_64 archive id: mac_x86_64 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: Rancher Desktop-mac.x86_64.zip path: MACX86_ZIP - name: Download mac aarch64 archive id: mac_aarch64 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: Rancher Desktop-mac.aarch64.zip path: MACARM_ZIP - name: Download build information id: info uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: build-info.yml path: RD_BUILD_INFO - run: node scripts/ts-wrapper.js scripts/populate-update-server.ts env: RD_SETUP_MSI: ${{ steps.msi.outputs.download-path }} RD_MACX86_ZIP: ${{ steps.mac_x86_64.outputs.download-path }} RD_MACARM_ZIP: ${{ steps.mac_aarch64.outputs.download-path }} RD_BUILD_INFO: ${{ steps.info.outputs.download-path }} RD_OUTPUT_DIR: ${{ github.workspace }}/pages GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_ACTOR: ${{ github.actor }} ================================================ FILE: .github/workflows/windows-e2e.yaml ================================================ name: e2e tests on Windows on: workflow_dispatch: push: branches-ignore: - 'dependabot/**' pull_request: {} defaults: run: shell: powershell jobs: check-paths: uses: ./.github/workflows/paths-ignore.yaml e2e-tests: needs: check-paths if: needs.check-paths.outputs.should-run == 'true' timeout-minutes: 90 runs-on: windows-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: ./.github/actions/setup-environment - uses: ./.github/actions/yarn-install - name: Run e2e Tests run: yarn test:e2e env: RD_DEBUG_ENABLED: '1' - name: Upload failure reports uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: always() with: name: e2etest-artifacts path: ./e2e/reports/* ================================================ FILE: .github/workflows/yarn-dedupe.yaml ================================================ name: Deduplicate yarn.lock on: schedule: - cron: '0 9 1 * *' workflow_dispatch: {} permissions: contents: write pull-requests: write jobs: check-for-token: outputs: has-token: ${{ steps.calc.outputs.HAS_SECRET }} runs-on: ubuntu-latest steps: - id: calc run: echo "HAS_SECRET=${HAS_SECRET}" >> "${GITHUB_OUTPUT}" env: HAS_SECRET: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW != '' }} yarn-dedupe: needs: check-for-token if: needs.check-for-token.outputs.has-token == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - uses: ./.github/actions/yarn-install - run: ./scripts/yarn-dedupe.sh --push env: GITHUB_TOKEN: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW }} ================================================ FILE: .gitignore ================================================ *.exe /bats/bats.tar.gz /bats/bin/ /bats/logs/ /coverage/ /dist/ /e2e/reports/ /go.work.sum /node_modules/ /resources/cert-manager* /resources/darwin/ /resources/host/ /resources/linux/* !/resources/linux/rancher-desktop.desktop /resources/preload.js* /resources/rancher-dashboard/ /resources/rdx-proxy.tar /resources/spin-operator* /resources/win32/ /screenshots/output/ /src/go/rdctl/pkg/options/generated/*.go /e2e/e2e/test-results/.last-run.json /.yarn/ ================================================ FILE: .gitmodules ================================================ [submodule "bats/bats-core"] path = bats/bats-core url = https://github.com/rancher-sandbox/bats-core.git branch = master [submodule "bats/bats-assert"] path = bats/bats-assert url = https://github.com/rancher-sandbox/bats-assert.git branch = master [submodule "bats/bats-support"] path = bats/bats-support url = https://github.com/rancher-sandbox/bats-support.git branch = master [submodule "bats/bats-file"] path = bats/bats-file url = https://github.com/rancher-sandbox/bats-file.git branch = master ================================================ FILE: .golangci.yaml ================================================ version: "2" linters: enable: - bodyclose - copyloopvar - dogsled - dupl - errcheck - goconst - gocritic - goprintffuncname - gosec - govet - ineffassign - misspell - mnd - nakedret - noctx - nolintlint - staticcheck - unconvert - unparam - unused - whitespace settings: dupl: threshold: 100 goconst: min-len: 2 min-occurrences: 3 gocritic: disabled-checks: - dupImport # https://github.com/go-critic/go-critic/issues/845 - ifElseChain - unnamedResult enabled-tags: - diagnostic - experimental - opinionated - performance - style gosec: excludes: # Taint analysis rules (G7xx) produce only false positives in this codebase - G702 # command injection via taint analysis - stub binaries forwarding args - G703 # path traversal via taint analysis - os.CreateTemp paths - G704 # SSRF via taint analysis - hardcoded URLs - G705 # XSS via taint analysis - stdout writes - G706 # log injection via taint analysis - internal log calls # G115 flags every int↔uintptr cast on file descriptors (os.NewFile, # syscall.Shutdown, IoctlFileClone). File descriptors are always # non-negative and these casts are idiomatic Go. - G115 # integer overflow conversion # G117 matches exported struct fields named "Password" etc. The # ConnectionInfo.Password field in rdctl/pkg/config is intentional. - G117 # exported field matches secret pattern config: G306: "0644" mnd: # don't include the "operation" and "assign" checks: - argument - case - condition - return ignored-numbers: - "0" - "1" - "2" - "3" ignored-functions: - ^make$ - ^net\.IPv4$ - ^os\.FileMode$ - ^os\.Mkdir(?:All)?$ - ^os\.(?:Open|Write)File$ - ^strings\.SplitN$ - ^tabwriter\.NewWriter$ - ^utils\.GetParentDir$ nolintlint: allow-unused: false # report any unused nolint directives require-explanation: true require-specific: true exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling rules: - linters: - errcheck - gocritic - gosec path: _test\.go - # Exclude bodyclose when it's passed to client.ProcessRequestForAPI # or client.ProcessRequestForUtility which internally closes the body. linters: - bodyclose path: src/go/rdctl/ source: client.ProcessRequestFor(API|Utility)\(rdClient.DoRequest(WithPayload)?\( - # Exclude ST1005 when it encounters errors starting with proper noun linters: - staticcheck path: src/go/wsl-helper/cmd/kubeconfig.go text: 'ST1005:' source: errors.New\("Windows - # Exclude ST1005 when it encounters errors starting with proper noun linters: - staticcheck path: src/go/rdctl/pkg/lock/lock.go text: 'ST1005:' source: fmt.Errorf\("Rancher Desktop - # Exclude the FIXME comments from upstream linters: - gocritic path: src/go/wsl-helper/pkg/dockerproxy/platform/vsock_linux\.go text: todoCommentWithoutDetail - # Ignore errors from syscall linters: - dogsled source: ^\s*_, _, _ = .*\.Call\( - # Ignore foreign constants linters: - staticcheck path: src/go/rdctl/pkg/process/process_darwin.go text: 'ST1003:' source: ^\s*(CTL_KERN|KERN_PROCARGS)\s*= - # Ignore foreign constants linters: - staticcheck path: src/go/rdctl/pkg/process/process_windows.go text: 'ST1003:' source: ^\s*type\s+[A-Z0-9_]+\s+struct - # Ignore foreign constants linters: - staticcheck path: src/go/rdctl/pkg/process/process_windows.go text: 'ST1003:' source: ^\s*[A-Z0-9_]+\s+= - # Don't de-duplicate different commands. linters: - dupl path: src/go/rdctl/cmd/extension(Install|Uninstall)\.go$ - # Don't use %q for registry files to avoid escaping backslashes linters: - gocritic path: src/go/rdctl/pkg/reg/reg.go text: 'sprintfQuotedString:' - # This seems inconsistent across platforms path: src/go/nerdctl-stub/main_shared.go linters: [ unparam ] text: \bresult\b.*\bis always nil\b source: func mountArgProcessor formatters: enable: - gofmt - gci exclusions: generated: lax settings: gci: sections: - standard - default - prefix(github.com/rancher-sandbox/rancher-desktop) # localmodule for go.work ================================================ FILE: .yarnrc.yml ================================================ enableScripts: false nodeLinker: node-modules plugins: - path: .yarn/plugins/plugin-rancher-desktop-license-checker.cjs ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Rancher Desktop Rancher Desktop accepts contributions via GitHub pull requests. This document outlines the process to get your pull request accepted. ## Start With An Issue Prior to creating a pull request it is a good idea to [create an issue]. This is especially true if the change request is something large. The bug, feature request, or other type of issue can be discussed prior to creating the pull request. This can reduce rework. [create an issue]: https://github.com/rancher-sandbox/rancher-desktop/issues/new ## Sign Your Commits A sign-off is a line at the end of the explanation for a commit. All commits must be signed. Your signature certifies that you wrote the patch or otherwise have the right to contribute the material. When you sign off you agree to the following rules (from [developercertificate.org](https://developercertificate.org/)): ``` Developer Certificate of Origin Version 1.1 Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 1 Letterman Drive Suite D4700 San Francisco, CA, 94129 Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Developer's Certificate of Origin 1.1 By making a contribution to this project, I certify that: (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. (d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. ``` Then you add a line to every git commit message: Signed-off-by: Joe Smith Use your real name (sorry, no pseudonyms or anonymous contributions). If you set your `user.name` and `user.email` git configs, you can sign your commit automatically with `git commit -s`. Note: If your git config information is set properly then viewing the `git log` information for your commit will look something like this: ``` Author: John Smith Date: Thu Feb 2 11:41:15 2018 -0800 Update README Signed-off-by: John Smith ``` Notice the `Author` and `Signed-off-by` lines match. If they don't your PR will be rejected by the automated DCO check. ## Pull Requests Pull requests for a code change should reference the issue they are related to. This will enable issues to serve as a central point of reference for a change. For example, if a pull request fixes or completes an issue, the commit or pull request should include: ```md Closes #123 ``` In this case 123 is the corresponding issue number. ### When End-To-End Tests Fail Every pull request triggers a full run of testing in the CI system. The failures reported by the code style checker (aka the "linter") and the unit tests are usually clear and easy to fix (and can be avoided by running `yarn test` locally before creating a commit). But when an integration, or e2e test, fails, it's sometimes useful to consult the log files for the run. 1. Click on the _Details_ link next to the failing E2E test notification, and navigate to the summary view of the test. ![Failure summary screenshot](docs/assets/images/contributing/e2e-summary.png) 2. From the bottom of the summary view, locate the `failure-reports.zip` link and download it. (You must be logged in to GitHub to be able to download that file.) ![Failure reports screenshot](docs/assets/images/contributing/e2e-failure-reports.png) 3. Extract that file to find the logs; they are in directories named after each test. For example, a subset of the log files may include: ``` $ ls -l total 62204 drwxr-xr-x 29 nobody nobody 928 Oct 12 2020 backend.e2e.spec.ts-logs -rw-r--r-- 1 nobody nobody 31616936 Oct 12 2020 backend.e2e.spec.ts-pw-trace.zip $ ls backend.e2e.spec.ts-logs/ background.log k8s.log networking.log commandLine.log kube.log protocol-handler.log dashboardServer.log lima.ha.stderr.log server.log deploymentProfile.log lima.ha.stdout.log settings.log diagnostics.log lima.log shortcuts.log extensions.log lima.serial.log steve.log images.log moby.log update.log integrations.log mock.log window_browser.log k3s.log nerdctl.log wsl.log ``` 4. It may be useful to go to https://trace.playwright.dev/ to examine the Playwright traces; they are the files named `*-pw-trace.zip`. This can be useful for seeing the state of the UI when waiting for elements to appear, disappear, etc. ## Semantic Versioning Rancher Desktop follows [semantic versioning](https://semver.org/). This does not cover Kubernetes or other tools provided by Rancher Desktop. Kubernetes has its own [release versioning](https://github.com/kubernetes/community/blob/master/contributors/design-proposals/release/versioning.md#kubernetes-release-versioning) scheme that looks like SemVer but is semantically different. ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # Rancher Desktop [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/rancher-sandbox/rancher-desktop) Rancher Desktop is an open-source project that brings Kubernetes and container management to the desktop. It runs on Windows, macOS and Linux. This README pertains to the development of Rancher Desktop. For user-oriented information about Rancher Desktop, please see [rancherdesktop.io][home]. For user-oriented documentation, please see [docs.rancherdesktop.io][docs]. [home]: https://rancherdesktop.io [docs]: https://docs.rancherdesktop.io ## Overview Rancher Desktop is an Electron application that is mainly written in TypeScript. It bundles a variety of other technologies in order to provide one cohesive application. It includes a command line tool, `rdctl`, which is written in Go. Most developer activities, such as running a development build, building/packaging Rancher Desktop, running unit tests, and running end-to-end tests, are done through `yarn` scripts. Some exceptions exist, such as running BATS tests. ## Setup ### Windows There are two options for building from source on Windows: with a [Development VM Setup](#development-vm-setup) or [Manual Development Environment Setup](#manual-development-environment-setup) with an existing Windows installation. #### Development VM Setup 1. Download a Microsoft Windows 10 [development virtual machine]. All of the following steps should be done in that virtual machine. 2. Open a PowerShell prompt (hit Windows Key + `X` and open `Windows PowerShell`). 3. Run the [automated setup script]: ```powershell Set-ExecutionPolicy RemoteSigned -Scope CurrentUser iwr -useb 'https://github.com/rancher-sandbox/rancher-desktop/raw/main/scripts/windows-setup.ps1' | iex ``` 4. Close the privileged PowerShell prompt. 5. Ensure `msbuild_path` and `msvs_version` are configured correctly in `.npmrc` file. Run the following commands to set these properties: ``` npm config set msvs_version npm config set msbuild_path ``` For example for Visual Studio 2022: ``` npm config set msvs_version 2022 npm config set msbuild_path "C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" ``` If you get an error message when trying to run `npm config set...`, run `npm config edit` and then add lines like ``` msvs_version=2022 msbuild_path=C:\Program Files (x86)\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe ``` Do not quote the values to the right side of the equal sign. The quotes aren't needed, and it's possible that some processors will treat them as literal parts of the path, and then fail. 7. Configure `git` to work with linux- and macos-originated files: ``` git config --global --replace-all core.autocrlf false git config --global --replace-all core.eol lf ``` If you find the `lint:go` tests are failing mysteriously, it's possible that the line-endings are incorrect. You can now clone the repository and run `yarn`. [development virtual machine]: https://developer.microsoft.com/en-us/windows/downloads/virtual-machines/ [automated setup script]: ./scripts/windows-setup.ps1 #### Manual Development Environment Setup 1. Install [Windows Subsystem for Linux (WSL)] on your machine. Skip this step, if WSL is already installed. 2. Open a PowerShell prompt (hit Windows Key + `X` and open `Windows PowerShell`). 3. Install [Scoop] via `iwr -useb get.scoop.sh | iex`. 4. Install 7zip, git, go, mingw, nvm, and unzip via `scoop install 7zip git go mingw nvm python unzip`. Check node version with `nvm list`. If node v22 is not installed or set as the current version, then install using `nvm install 22` and set as current using `nvm use 22.xx.xx`. 5. Install the yarn package manager via `npm install --global yarn` 6. Install Visual Studio 2017 or higher. As of this writing the latest version is available at [https://visualstudio.microsoft.com/downloads/]; if that's changed, a good search engine should find it. 7. Make sure you have the `Windows SDK` component installed. This [Visual Studio docs] describes steps to install components. The [Desktop development with C++] workload needs to be selected, too. 8. Configure `git` to work with linux- and macos-originated files: ``` git config --global --replace-all core.autocrlf false git config --global --replace-all core.eol lf ``` If you find the `lint:go` tests are failing mysteriously, it's possible that the line-endings are incorrect. 9. Ensure `msbuild_path` and `msvs_version` are configured correctly in `.npmrc` file. Run the following commands to set these properties: ``` npm config set msvs_version npm config set msbuild_path ``` For example for Visual Studio 2022: ``` npm config set msvs_version 2022 npm config set msbuild_path "C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" ``` If you get an error message when trying to run `npm config set...`, run `npm config edit` and then add lines like ``` msvs_version=2022 msbuild_path=C:\Program Files (x86)\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe ``` Do not quote the values to the right side of the equal sign. They aren't needed, and it's possible that some processor will treat them as literal parts of the path, and then fail. You can now clone the repository and run `yarn`. [Scoop]: https://scoop.sh/ [Visual Studio docs]: https://docs.microsoft.com/en-us/visualstudio/install/modify-visual-studio?view=vs-2022 [Windows Subsystem for Linux (WSL)]: https://docs.microsoft.com/en-us/windows/wsl/install [Desktop development with C++]: https://learn.microsoft.com/en-us/visualstudio/install/modify-visual-studio?view=vs-2022#change-workloads-or-individual-components ### macOS Install `nvm` to get Node.js and npm: See https://github.com/nvm-sh/nvm#installing-and-updating and run the `curl` or `wget` command to install nvm. Note that this script adds code dealing with `nvm` to a profile file (like `~/.bash_profile`). To add access to `nvm` to a current shell session, you'll need to `source` that file. Currently we build Rancher Desktop with Node 22. To install it, run: ``` nvm install 22.14 ``` Next, you'll need to install the yarn package manager: ``` npm install --global yarn ``` You'll also need to run `brew install go` if you haven't installed go. Then you can install dependencies with: ``` yarn ``` > ### ⚠️ Working on a mac with an M1 chip? > > You will need to set the `M1` environment variable before installing dependencies and running any npm scripts: > > ``` > export M1=1 > yarn > ``` > > You will want to run `git clean -fdx` to clean out any cached assets and re-downloaded with the correct arch before running `yarn` if you previously installed dependencies without setting `M1` first. ### Linux Ensure you have the following installed: - [Node.js][Node.js] v22. **Make sure you have any development packages installed.** For example, on openSUSE Leap 15.6 you would need to install `nodejs22` and `nodejs22-devel`. - [yarn classic][yarn-classic] - Go 1.22 or later. - Dependencies described in the [`node-gyp` docs][node-gyp] installation. This is required to install the [`ffi-napi`][ffi-napi] npm package. These docs mention "a proper C/C++ compiler toolchain". You can install `gcc` and `g++` for this. Then you can install dependencies with: ``` yarn ``` You can then run Rancher Desktop as described below. It may fail on the first run - if this happens, try doing a factory reset and re-running, which has been known to solve this issue. [Node.js]: https://nodejs.org/ [ffi-napi]: https://www.npmjs.com/package/ffi-napi [node-gyp]: https://github.com/nodejs/node-gyp#on-unix [yarn-classic]: https://classic.yarnpkg.com/lang/en/docs/install/#debian-stable ## Running Once you have your dependencies installed you can run a development version of Rancher Desktop with: ``` yarn dev ``` ## Tests To run the unit tests: ``` yarn test ``` To run the integration tests: ``` yarn test:e2e ``` ## Building Rancher can be built from source on Windows, macOS or Linux. Cross-compilation is currently not supported. To run a build do: ``` yarn build yarn package ``` The build output goes to `dist/`. ### Debugging builds with the Chrome remote debugger The Chrome remote debugger allows you to debug Electron apps using Chrome Developer Tools. You can use it to access log messages that might output to the developer console of the renderer process. This is especially helpful for getting additional debug information in production builds of Rancher Desktop. #### Starting Rancher Desktop with Remote Debugging Enabled To enable remote debugging, start Rancher Desktop with the `--remote-debugging-port` argument. On Linux, start Rancher Desktop with the following command: ``` bash rancher-desktop --remote-debugging-port="8315" --remote-allow-origins=http://localhost:8315 ``` On macOS, start Rancher Desktop with the following command: ``` /Applications/Rancher\ Desktop.app/Contents/MacOS/Rancher\ Desktop --remote-debugging-port="8315" --remote-allow-origins=http://localhost:8315 ``` On Windows, start Rancher Desktop with the following command: ``` powershell cd 'C:\Program Files\Rancher Desktop\' & '.\Rancher Desktop.exe' --remote-debugging-port="8315" --remote-allow-origins=http://localhost:8315 ``` After Rancher Desktop starts, open Chrome and navigate to `http://localhost:8315/`. Select the available target to start remote debugging Rancher Desktop. ![image](https://github.com/rak-phillip/rancher-desktop/assets/835961/4f5fcb33-d381-4900-a836-685eab3af441) ![image](https://github.com/rak-phillip/rancher-desktop/assets/835961/91b4ee63-7093-4377-b8b3-f2f4a57a16a7) #### Remote Debugging an Extension To remote debug an extension, follow the same process as remote debugging a build. However, you will need to load an extension before navigating to `http://localhost:8315/`. Both Rancher Desktop and the loaded extension should be listed as available targets. ![image](https://github.com/rak-phillip/rancher-desktop/assets/835961/71bb7eec-38e5-4744-a547-ebb36048918a) ![image](https://github.com/rak-phillip/rancher-desktop/assets/835961/f4aad3e1-dabc-473e-9404-05609216cd03) ### Debugging dev env with GoLand The following steps have been tested with GoLand on Linux but might work for other JetBrains IDEs in a similar way. 1. Install the Node.js plugin (via `File > Settings > Plugins`) ![image](https://github.com/s0nea/rancher-desktop/assets/8761082/f9574abb-06d9-4132-a14b-c3d445e87f7d) 2. Go to the "Run/Debug Configurations" dialog (via `Run > Edit Configurations...`) 3. Add a new Node.js configuration with the following settings: - Name: a name for the debug configuration, e.g. `rancher desktop` - Node interpreter: choose your installed node interpreter, e.g. `/usr/bin/node` - Node parameters: `scripts/ts-wrapper.js scripts/dev.ts` - Working directory: choose the working directory of your project, e.g. `~/src/rancher-desktop` ![image](https://github.com/s0nea/rancher-desktop/assets/8761082/41686095-04ba-4d9e-bac1-b5587d146381) 4. Save the configuration 5. You can now set a breakpoint and click "Debug 'rancher desktop'" to start debugging ![image](https://github.com/s0nea/rancher-desktop/assets/8761082/87ea45f4-0a4d-4a52-9f3b-866c45e3fe2a) ## Development Builds ### Windows and macOS Each commit triggers a GitHub Actions run that results in application bundles (`.exe`s and `.dmg`s) being uploaded as artifacts. This can be useful if you want to test the latest build of Rancher Desktop as built by the build system. You can download these artifacts from the Summary page of completed `package` actions. ### Linux Similar to Windows and macOS, Linux builds of Rancher Desktop are made from each commit. However on Linux, only part of the process is done by GitHub Actions. The final part of it is done by [Open Build Service][OBS]. There are two channels of the Rancher Desktop repositories: `dev` and `stable`. `stable` is the channel that most users use. It is the one that users are instructed to add in the official [documentation][docs], and the one that contains builds that are created from official releases. `dev` is the channel that we are interested in here: it contains builds created from the latest commit made on the `main` branch, and on any branches that match the format `release-*`. To learn how to install the development repositories, see below. When using the `dev` repositories, it is important to understand the format of the versions of Rancher Desktop available from the `dev` repositories. The versions are in the format: ``` ... ``` where: `priority` is a meaningless number that exists to give versions built from the `main` branch priority over versions built from the `release-*` branches when updating. `branch` is the branch name; dashes are removed due to constraints imposed by package formats. `commit_time` is the UNIX timestamp of the commit used to make the build. `commit` is the shortened hash of the commit used to make the build. [docs]: https://docs.rancherdesktop.io [OBS]: https://build.opensuse.org/ #### `.deb` Development Repository You can add the repo with the following steps: ``` curl -s https://download.opensuse.org/repositories/isv:/Rancher:/dev/deb/Release.key | gpg --dearmor | sudo dd status=none of=/usr/share/keyrings/isv-rancher-dev-archive-keyring.gpg echo 'deb [signed-by=/usr/share/keyrings/isv-rancher-dev-archive-keyring.gpg] https://download.opensuse.org/repositories/isv:/Rancher:/dev/deb/ ./' | sudo dd status=none of=/etc/apt/sources.list.d/isv-rancher-dev.list sudo apt update ``` You can see available versions with: ``` apt list -a rancher-desktop ``` Once you find the version you want to install you can install it with: ``` sudo apt install rancher-desktop= ``` This works even if you already have a version of Rancher Desktop installed. #### `.rpm` Development Repository You can add the repo with: ``` sudo zypper addrepo https://download.opensuse.org/repositories/isv:/Rancher:/dev/rpm/isv:Rancher:dev.repo sudo zypper refresh ``` You can see available versions with: ``` zypper search -s rancher-desktop ``` Finally, install the version you want with: ``` zypper install --oldpackage rancher-desktop= ``` This works even if you already have a version of Rancher Desktop installed. #### Development AppImages There are no repositories for AppImages, but you can access the [latest development AppImage builds]. [latest development AppImage builds]: https://download.opensuse.org/repositories/isv:/Rancher:/dev/AppImage/ ## API Rancher Desktop supports a limited HTTP-based API. The API is defined in `pkg/rancher-desktop/assets/specs/command-api.yaml`, and you can see examples of how it's invoked in the client code at `go/src/rdctl`. ### Stability The API is currently at version 1, but is still considered internal and experimental, and is subject to change without any advance notice. At some point we expect that necessary changes to the API will go through a warning and deprecation notice. ## Contributing Please see [the document about contributing](CONTRIBUTING.md). ## Further Reading Please see the [docs](docs/development/) directory for further developer documentation. ================================================ FILE: babel.config.cjs ================================================ const packageJson = require('./package.json'); const electronVersion = parseInt(/\d+/.exec(packageJson.devDependencies.electron), 10); module.exports = { presets: [ [ '@vue/cli-plugin-babel/preset', { useBuiltIns: false }, ], [ '@babel/preset-env', { targets: { node: 'current', electron: electronVersion, }, }, ], ], env: { test: { presets: [ ['@babel/env', { targets: { node: 'current' } }, ], ], }, }, plugins: [ '@babel/plugin-proposal-class-properties', '@babel/plugin-proposal-nullish-coalescing-operator', '@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-private-methods', '@babel/plugin-proposal-private-property-in-object', ], }; ================================================ FILE: background.ts ================================================ import { spawn } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; import util from 'util'; import Electron, { MessageBoxOptions, nativeTheme } from 'electron'; import _ from 'lodash'; import semver from 'semver'; import { State } from '@pkg/backend/backend'; import BackendHelper from '@pkg/backend/backendHelper'; import K8sFactory from '@pkg/backend/factory'; import { getImageProcessor } from '@pkg/backend/images/imageFactory'; import { ImageProcessor } from '@pkg/backend/images/imageProcessor'; import * as K8s from '@pkg/backend/k8s'; import { Steve } from '@pkg/backend/steve'; import { FatalCommandLineOptionError, LockedFieldError, updateFromCommandLine } from '@pkg/config/commandLineOptions'; import { Help } from '@pkg/config/help'; import * as settings from '@pkg/config/settings'; import * as settingsImpl from '@pkg/config/settingsImpl'; import { TransientSettings } from '@pkg/config/transientSettings'; import { IntegrationManager, getIntegrationManager } from '@pkg/integrations/integrationManager'; import { PathManagementStrategy, PathManager } from '@pkg/integrations/pathManager'; import { getPathManagerFor } from '@pkg/integrations/pathManagerImpl'; import { BackendState, CommandWorkerInterface, HttpCommandServer } from '@pkg/main/commandServer/httpCommandServer'; import SettingsValidator from '@pkg/main/commandServer/settingsValidator'; import { ContainerExecHandler } from '@pkg/main/containerExec'; import { HttpCredentialHelperServer } from '@pkg/main/credentialServer/httpCredentialHelperServer'; import { DashboardServer } from '@pkg/main/dashboardServer'; import { DeploymentProfileError, readDeploymentProfiles } from '@pkg/main/deploymentProfiles'; import { DiagnosticsManager, DiagnosticsResultCollection } from '@pkg/main/diagnostics/diagnostics'; import { ExtensionErrorCode, isExtensionError } from '@pkg/main/extensions'; import { ImageEventHandler } from '@pkg/main/imageEvents'; import { getIpcMainProxy } from '@pkg/main/ipcMain'; import mainEvents from '@pkg/main/mainEvents'; import buildApplicationMenu from '@pkg/main/mainmenu'; import setupNetworking from '@pkg/main/networking'; import { Snapshots } from '@pkg/main/snapshots/snapshots'; import { Snapshot, SnapshotDialog } from '@pkg/main/snapshots/types'; import { Tray } from '@pkg/main/tray'; import setupUpdate from '@pkg/main/update'; import { spawnFile } from '@pkg/utils/childProcess'; import getCommandLineArgs from '@pkg/utils/commandLine'; import dockerDirManager from '@pkg/utils/dockerDirManager'; import { isDevEnv } from '@pkg/utils/environment'; import Logging, { clearLoggingDirectory, setLogLevel } from '@pkg/utils/logging'; import { fetchMacOsVersion, getMacOsVersion } from '@pkg/utils/osVersion'; import paths from '@pkg/utils/paths'; import { protocolsRegistered, setupProtocolHandlers } from '@pkg/utils/protocols'; import { executable } from '@pkg/utils/resources'; import { jsonStringifyWithWhiteSpace } from '@pkg/utils/stringify'; import { RecursivePartial, RecursiveReadonly } from '@pkg/utils/typeUtils'; import { getVersion } from '@pkg/utils/version'; import getWSLVersion from '@pkg/utils/wslVersion'; import * as window from '@pkg/window'; import { closeDashboard, openDashboard } from '@pkg/window/dashboard'; import { openPreferences, preferencesSetDirtyFlag } from '@pkg/window/preferences'; // https://www.electronjs.org/docs/latest/breaking-changes#changed-gtk-4-is-default-when-running-gnome if (process.platform === 'linux') { Electron.app.commandLine.appendSwitch('gtk-version', '3'); } Electron.app.setPath('userData', path.join(paths.appHome, 'electron')); Electron.app.setPath('cache', paths.cache); Electron.app.setAppLogsPath(paths.logs); const console = Logging.background; // Do an early check for debugging enabled via the environment variable so that // we can turn on extra logging to troubleshoot startup issues. if (settingsImpl.runInDebugMode(false)) { setLogLevel('debug'); } if (!Electron.app.requestSingleInstanceLock()) { process.exit(201); } clearLoggingDirectory(); const SNAPSHOT_OPERATION = 'Snapshot operation in progress'; const ipcMainProxy = getIpcMainProxy(console); const k8smanager = newK8sManager(); const diagnostics: DiagnosticsManager = new DiagnosticsManager(); let cfg: settings.Settings; let firstRunDialogComplete = false; let gone = false; // when true indicates app is shutting down let imageEventHandler: ImageEventHandler | null = null; let containerExecHandler: ContainerExecHandler | null = null; let currentContainerEngine = settings.ContainerEngine.NONE; let currentImageProcessor: ImageProcessor | null = null; let enabledK8s: boolean; let pathManager: PathManager; const integrationManager: IntegrationManager = getIntegrationManager(); let noModalDialogs = false; // Indicates whether the UI should be locked, settings changes should be disallowed // and possibly other things should be disallowed. As of the time of writing, // set to true when a snapshot is being created or restored. let deploymentProfiles: settings.DeploymentProfileType = { defaults: {}, locked: {} }; /** * pendingRestartContext is needed because with the CLI it's possible to change * the state of the system without using the UI. This can push the system out * of sync, for example setting kubernetes-enabled=true while it's disabled. * Normally the code restarts the system when processing the SET command, but if * the backend is currently starting up or shutting down, we have to wait for it * to finish. This module gets a `state-changed` event when that happens, * and if this flag is true, a new restart can be triggered. */ let pendingRestartContext: CommandWorkerInterface.CommandContext | undefined; let httpCommandServer: HttpCommandServer | null = null; const httpCredentialHelperServer = new HttpCredentialHelperServer(); if (process.platform === 'linux') { // On Linux, put Electron into a new process group so that we can more // reliably kill processes we spawn from extensions. import('posix-node').then(({ default: { setpgid } }) => { setpgid?.(0, 0); }).catch(ex => { console.error(`Ignoring error setting process group: ${ ex }`); }); } // Scheme must be registered before the app is ready Electron.protocol.registerSchemesAsPrivileged([ { scheme: 'app', privileges: { secure: true, standard: true } }, ]); process.on('unhandledRejection', (reason: any, promise: any) => { if (reason.code === 'ECONNREFUSED' && reason.port === cfg.kubernetes.port) { // Do nothing: a connection to the kubernetes server was broken } else { console.error('UnhandledRejectionWarning:', reason); } }); Electron.app.on('second-instance', async() => { await protocolsRegistered; console.warn('A second instance was started'); if (firstRunDialogComplete) { window.openMain(); } }); // takes care of any propagation of settings we want to do // when settings change mainEvents.on('settings-update', async(newSettings) => { console.log(`mainEvents settings-update: ${ JSON.stringify(newSettings) }`); nativeTheme.themeSource = newSettings.application.theme; const runInDebugMode = settingsImpl.runInDebugMode(newSettings.application.debug); if (runInDebugMode) { setLogLevel('debug'); } else { setLogLevel('info'); } k8smanager.debug = runInDebugMode; if (gone) { console.debug('Suppressing settings-update because app is quitting'); return; } await setPathManager(newSettings.application.pathManagementStrategy); await pathManager.enforce(); if (newSettings.application.hideNotificationIcon) { Tray.getInstance(cfg).hide(); } else { if (firstRunDialogComplete) { Tray.getInstance(cfg).show(); } mainEvents.emit('k8s-check-state', k8smanager); } await runRdctlSetup(newSettings); window.send('preferences/changed'); }); mainEvents.handle('settings-fetch', () => { return Promise.resolve(cfg); }); Electron.protocol.registerSchemesAsPrivileged([{ scheme: 'app' }, { scheme: 'x-rd-extension', privileges: { standard: true, secure: true, bypassCSP: true, allowServiceWorkers: true, supportFetchAPI: true, corsEnabled: true, }, }]); Electron.app.whenReady().then(async() => { try { const commandLineArgs = getCommandLineArgs(); // Normally `noModalDialogs` is set when we call `updateFromCommandLine(.., commandLineArgs)` // But if there's an error either in that function, or before, we'll need to know if we should // display the error in a modal-dialog or not. So check the current command-line arguments for that. // // It's very unlikely that a string option is set to this exact string though. // `rdctl start --images.namespace --no-modal-dialogs` // is syntactically correct, but unlikely (because why would someone create a // containerd namespace called "--no-modal-dialogs"? noModalDialogs = commandLineArgs.includes('--no-modal-dialogs'); setupProtocolHandlers(); // make sure we have the macOS version cached before calling getMacOsVersion() if (os.platform() === 'darwin') { await fetchMacOsVersion(console); } // Needs to happen before any file is written; otherwise, that file // could be owned by root, which will lead to future problems. if (['linux', 'darwin'].includes(os.platform())) { await checkForRootPrivs(); } // Check for required OS versions and features await checkPrerequisites(); DashboardServer.getInstance().init(); await setupNetworking(); try { deploymentProfiles = await readDeploymentProfiles(); } catch (ex: any) { if (ex instanceof DeploymentProfileError) { await handleFailure(ex); } else { console.log(`Got an unexpected deployment profile error ${ ex }`, ex); } throw ex; } try { cfg = settingsImpl.load(deploymentProfiles); nativeTheme.themeSource = cfg.application.theme; settingsImpl.updateLockedFields(deploymentProfiles.locked); } catch (err: any) { const titlePart = err.name || 'Failed to load settings'; const message = err.message || err.toString(); showErrorDialog(titlePart, message, true); // showErrorDialog doesn't exit immediately; avoid running the rest of the function return; } try { // The profile loader did rudimentary type-validation on profiles, but the validator checks for things // like invalid strings for application.pathManagementStrategy. validateEarlySettings(settings.defaultSettings, deploymentProfiles.defaults, {}); validateEarlySettings(settings.defaultSettings, deploymentProfiles.locked, {}); if (commandLineArgs.length) { cfg = updateFromCommandLine(cfg, settingsImpl.getLockedSettings(), commandLineArgs); k8smanager.noModalDialogs = noModalDialogs = TransientSettings.value.noModalDialogs; } } catch (err: any) { noModalDialogs = TransientSettings.value.noModalDialogs; if (err instanceof LockedFieldError || err instanceof DeploymentProfileError || err instanceof FatalCommandLineOptionError) { handleFailure(err).catch((err2: any) => { console.log('Internal error trying to show a failure dialog: ', err2); process.exit(2); }); // Avoid running the rest of the `whenReady` handler after calling this handleFailure -- shutdown is imminent return; } else if (!noModalDialogs) { showErrorDialog('Invalid command-line arguments', err.message, false); } console.log(`Failed to update command from argument ${ commandLineArgs.join(', ') }`, err); } httpCommandServer = new HttpCommandServer(new BackgroundCommandWorker()); await httpCommandServer.init(); await httpCredentialHelperServer.init(); await initUI(); await checkForBackendLock(); await setPathManager(cfg.application.pathManagementStrategy); await integrationManager.enforce(); mainEvents.emit('settings-update', cfg); // Set up the updater; we may need to quit the app if an update is already // queued. if (await setupUpdate(cfg.application.updater.enabled, true)) { gone = true; // The update code will trigger a restart; don't do it here, as it may not // be ready yet. console.log('Will apply update; skipping startup.'); return; } try { await dockerDirManager.ensureCredHelperConfigured(); } catch (ex: any) { const errorTitle = 'Error configuring credential helper'; console.error(`${ errorTitle }:`, ex); const title = ex.title ?? errorTitle; const message = ex.message ?? ex.toString(); showErrorDialog(title, message, true); } diagnostics.runChecks().catch(console.error); await startBackend(); } catch (ex: any) { console.error(`Error starting up: ${ ex }`, ex.stack); gone = true; Electron.app.quit(); } }); async function setPathManager(newStrategy: PathManagementStrategy) { if (pathManager) { if (pathManager.strategy === newStrategy) { return; } await pathManager.remove(); } pathManager = getPathManagerFor(newStrategy); } /** * Reads the 'backend.lock' file and returns its contents if it exists. * Returns null if the file doesn't exist. */ async function readBackendLockFile(): Promise<{ action: string } | null> { try { const fileContents = await fs.promises.readFile( path.join(paths.appHome, 'backend.lock'), 'utf-8', ); return JSON.parse(fileContents); } catch (ex: any) { if (ex.code === 'ENOENT') { return null; } else { throw ex; } } } /** * Emits the 'backend-locked-update' event. */ function updateBackendLockState(backendIsLocked: string, action?: string): void { mainEvents.emit('backend-locked-update', backendIsLocked, action); } /** * Checks for the existence of the 'backend.lock' file and emits the * 'backend-locked-update' event to notify listeners about the current lock * status. */ async function doesBackendLockExist(): Promise { let backendIsLocked: string; const lockFileContents = await readBackendLockFile(); if (lockFileContents !== null) { backendIsLocked = SNAPSHOT_OPERATION; updateBackendLockState(backendIsLocked, lockFileContents.action); } else { backendIsLocked = ''; updateBackendLockState(backendIsLocked); } return !!backendIsLocked; } /** * Blocks execution until the 'backend.lock' file is no longer present. */ async function checkForBackendLock() { // Perform an initial check for a lock file if (!await doesBackendLockExist()) { return; } const startTime = Date.now(); // Check every second if a lock file exists while (await doesBackendLockExist()) { // Notify when a lock file has existed for more than 5 minutes if (Date.now() - startTime >= 300_000) { mainEvents.emit( 'dialog-info', { dialog: 'SnapshotsDialog', infoKey: 'snapshots.info.lock.info', }, ); } await util.promisify(setTimeout)(1_000); } } async function initUI() { await doFirstRunDialog(); if (gone) { console.log('User triggered quit during first-run'); return; } buildApplicationMenu(); Electron.app.setAboutPanelOptions({ // TODO: Update this to 2021-... as dev progresses // also needs to be updated in electron-builder.yml copyright: 'Copyright © 2021-2026 SUSE LLC', applicationName: `${ Electron.app.name } by SUSE`, applicationVersion: `Version ${ await getVersion() }`, iconPath: path.join(paths.resources, 'icons', 'logo-square-512.png'), }); if (!cfg.application.hideNotificationIcon) { Tray.getInstance(cfg).show(); } if (!cfg.application.startInBackground) { window.openMain(); } else if (Electron.app.dock) { Electron.app.dock.hide(); } } async function doFirstRunDialog() { if (!noModalDialogs && settingsImpl.firstRunDialogNeeded()) { await window.openFirstRunDialog(); } firstRunDialogComplete = true; } async function checkForRootPrivs() { if (isRoot()) { await window.openDenyRootDialog(); gone = true; Electron.app.quit(); } } async function checkPrerequisites() { const osPlatform = os.platform(); let messageId: window.reqMessageId = 'ok'; let args: any[] = []; switch (osPlatform) { case 'win32': { // Required: Windows 10-1909(build 18363) or newer const winRel = os.release().split('.'); if (Number(winRel[0]) < 10 || (Number(winRel[0]) === 10 && Number(winRel[2]) < 18363)) { messageId = 'win32-release'; } else { try { const version = await getWSLVersion(); if (version.outdated_kernel) { messageId = 'win32-kernel'; args = [version]; } } catch (ex) { console.error(`Failed to check WSL version, ignoring:`, ex); } } break; } case 'linux': { // TODO: This whole testing for nested virtualization is wrong. All we should test for is if // hardware acceleration is available, e.g. checking /proc/cpuinfo for "vmx" (Intel) or "svm" (AMD). if (process.arch === 'x64') { // Required: Nested virtualization enabled const nestedFiles = [ '/sys/module/kvm_amd/parameters/nested', '/sys/module/kvm_intel/parameters/nested']; messageId = 'linux-nested'; for (const nestedFile of nestedFiles) { try { const data = await fs.promises.readFile(nestedFile, { encoding: 'utf8' }); if (data && (data.toLowerCase().startsWith('y') || data.startsWith('1'))) { messageId = 'ok'; break; } } catch { } } } break; } case 'darwin': { // Required: macOS-10.15(Darwin-19) or newer if (semver.gt('10.15.0', getMacOsVersion())) { messageId = 'macOS-release'; } break; } } if (messageId !== 'ok') { await window.openUnmetPrerequisitesDialog(messageId, ...args); gone = true; Electron.app.quit(); } } /** * Check if there are any reasons that would mean it makes no sense to continue * starting the app. Should be invoked before attempting to start the backend. */ async function checkBackendValid() { const invalidReason = await k8smanager.getBackendInvalidReason(); if (invalidReason) { await handleFailure(invalidReason); gone = true; Electron.app.quit(); } } /** * Start the Kubernetes backend. * * @precondition cfg.kubernetes.version is set. */ async function startBackend() { await checkBackendValid(); // A string describing why we're ignoring the request. const ignoreReason = { [K8s.State.STOPPED]: undefined, // Normal start is accepted. [K8s.State.STARTING]: 'Ignoring duplicate attempt to start backend while starting backend.', [K8s.State.STARTED]: 'Ignoring attempt to start already-started backend.', [K8s.State.STOPPING]: 'Ignoring attempt to start backend while stopping.', [K8s.State.ERROR]: undefined, // Attempting start from error state is fine. [K8s.State.DISABLED]: 'Ignoring attempt to start already-started backend (Kubernetes disabled).', }[k8smanager.state]; if (ignoreReason) { console.debug(ignoreReason); return; } try { await startK8sManager(); } catch (err) { handleFailure(err); } finally { window.send('extensions/changed'); } } /** * Start the backend. * * @note Callers are responsible for handling errors thrown from here. */ async function startK8sManager() { const changedContainerEngine = currentContainerEngine !== cfg.containerEngine.name; currentContainerEngine = cfg.containerEngine.name; enabledK8s = cfg.kubernetes.enabled; if (changedContainerEngine) { setupImageProcessor(); } await k8smanager.start(cfg); const { initializeExtensionManager } = await import('@pkg/main/extensions/manager'); await initializeExtensionManager(k8smanager.containerEngineClient, cfg); window.send('extensions/changed'); if (!containerExecHandler) { containerExecHandler = new ContainerExecHandler(k8smanager.containerEngineClient); } else { containerExecHandler.updateClient(k8smanager.containerEngineClient); } } /** * We need to deactivate the current imageProcessor, if there is one, * so it stops processing events, * and also tell the image event-handler about the new image processor. * * Some container engines support namespaces, so we need to specify the current namespace * as well. It should be done here so that the consumers of the `current-engine-changed` * event will operate in an environment where the image-processor knows the current namespace. */ function setupImageProcessor() { const imageProcessor = getImageProcessor(cfg.containerEngine.name, k8smanager); currentImageProcessor?.deactivate(); if (!imageEventHandler) { imageEventHandler = new ImageEventHandler(imageProcessor); } imageEventHandler.imageProcessor = imageProcessor; currentImageProcessor = imageProcessor; currentImageProcessor?.activate(); currentImageProcessor.namespace = cfg.images.namespace; window.send('k8s-current-engine', cfg.containerEngine.name); } interface K8sError { errCode: number | string } function isK8sError(object: any): object is K8sError { return 'errCode' in object; } Electron.app.on('before-quit', async(event) => { if (gone) { mainEvents.emit('quit'); return; } event.preventDefault(); httpCommandServer?.closeServer(); httpCredentialHelperServer.closeServer(); try { await mainEvents.tryInvoke('extensions/shutdown'); await k8smanager?.stop(); await mainEvents.tryInvoke('shutdown-integrations'); console.log(`2: Child exited cleanly.`); } catch (ex: any) { console.log(`2: Child exited with code ${ isK8sError(ex) ? ex.errCode : (ex.errCode ?? '') }`); handleFailure(ex); } finally { gone = true; if (process.env['APPIMAGE']) { await integrationManager.removeSymlinksOnly(); } Electron.app.quit(); } }); Electron.app.on('window-all-closed', () => { // On macOS, hide the dock icon. Electron.app.dock?.hide(); }); Electron.app.on('activate', async() => { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (!firstRunDialogComplete) { console.log('Still processing the first-run dialog: not opening main window'); return; } await protocolsRegistered; window.openMain(); }); mainEvents.on('backend-locked-update', (backendIsLocked, action) => { if (backendIsLocked) { window.send('backend-locked', action); } else { window.send('backend-unlocked'); } }); mainEvents.on('backend-locked-check', async() => { await doesBackendLockExist(); }); ipcMainProxy.on('backend-state-check', async() => { await doesBackendLockExist(); }); ipcMainProxy.on('settings-read', (event) => { event.reply('settings-read', cfg); }); // This is the synchronous version of the above; we still use // ipcRenderer.sendSync in some places, so it's required for now. ipcMainProxy.on('settings-read', (event) => { console.debug(`event settings-read in main: ${ JSON.stringify(cfg) }`); event.returnValue = cfg; }); ipcMainProxy.on('images-namespaces-read', (event) => { if ([K8s.State.STARTED, K8s.State.DISABLED].includes(k8smanager.state)) { currentImageProcessor?.relayNamespaces(); } }); ipcMainProxy.on('dashboard-open', () => { openDashboard(); }); ipcMainProxy.on('dashboard-close', () => { closeDashboard(); }); ipcMainProxy.on('preferences-open', () => { openPreferences(); }); ipcMainProxy.on('preferences-close', () => { window.getWindow('preferences')?.close(); }); ipcMainProxy.on('preferences-set-dirty', (_event, dirtyFlag) => { preferencesSetDirtyFlag(dirtyFlag); }); ipcMainProxy.on('get-debugging-statuses', () => { window.send('is-debugging', settingsImpl.runInDebugMode(cfg.application.debug)); window.send('always-debugging', settingsImpl.runInDebugMode(false)); }); function writeSettings(arg: RecursivePartial>) { settingsImpl.save(settingsImpl.merge(cfg, arg)); mainEvents.emit('settings-update', cfg); } ipcMainProxy.handle('settings-write', (event, arg) => { writeSettings(arg); // dashboard requires kubernetes, so we want to close it if kubernetes is disabled if (arg?.kubernetes?.enabled === false) { closeDashboard(); } event.sender.sendToFrame(event.frameId, 'settings-update', cfg); }); mainEvents.on('settings-write', writeSettings); mainEvents.on('extensions/ui/uninstall', (id) => { window.send('ok:extensions/uninstall', id); }); mainEvents.on('dialog-info', (args) => { window.getWindow(args.dialog)?.webContents.send('dialog/info', args); }); ipcMainProxy.on('extensions/open', (_event, id, path) => { window.openExtension(id, path); }); ipcMainProxy.on('extensions/close', () => { window.closeExtension(); }); ipcMainProxy.handle('transient-settings-fetch', () => { return Promise.resolve(TransientSettings.value); }); ipcMainProxy.handle('transient-settings-update', (event, arg) => { TransientSettings.update(arg); }); ipcMainProxy.on('k8s-state', (event) => { event.returnValue = k8smanager.state; }); ipcMainProxy.on('k8s-current-engine', () => { window.send('k8s-current-engine', currentContainerEngine); }); ipcMainProxy.on('k8s-current-port', () => { window.send('k8s-current-port', k8smanager.kubeBackend.desiredPort); }); ipcMainProxy.on('k8s-reset', async(_, arg) => { await doK8sReset(arg, { interactive: true }); }); ipcMainProxy.handle('api-get-credentials', () => mainEvents.invoke('api-get-credentials')); ipcMainProxy.handle('get-locked-fields', () => settingsImpl.getLockedSettings()); function backendIsBusy() { return [K8s.State.STARTING, K8s.State.STOPPING].includes(k8smanager.state); } async function doK8sReset(arg: 'fast' | 'wipe' | 'fullRestart', context: CommandWorkerInterface.CommandContext): Promise { // If not in a place to restart than skip it if (backendIsBusy()) { console.log(`Skipping reset, invalid state ${ k8smanager.state }`); return; } try { switch (arg) { case 'fast': await k8smanager.reset(cfg); break; case 'fullRestart': await k8smanager.stop(); console.log(`Stopped Kubernetes backend cleanly.`); await startK8sManager(); break; case 'wipe': console.log('Deleting VM to reset...'); await k8smanager.del(); console.log(`Deleted VM to reset exited cleanly.`); await startK8sManager(); break; } } catch (ex) { if (context.interactive) { handleFailure(ex); } else { console.error(ex); } } } ipcMainProxy.on('k8s-restart', async() => { if (cfg.kubernetes.port !== k8smanager.kubeBackend.desiredPort) { // On port change, we need to wipe the VM. return doK8sReset('wipe', { interactive: true }); } else if (cfg.containerEngine.name !== currentContainerEngine || cfg.kubernetes.enabled !== enabledK8s) { return doK8sReset('fullRestart', { interactive: true }); } try { switch (k8smanager.state) { case K8s.State.STOPPED: case K8s.State.STARTED: case K8s.State.DISABLED: // Calling start() will restart the backend, possible switching versions // as a side-effect. await startK8sManager(); break; } } catch (ex) { handleFailure(ex); } }); ipcMainProxy.on('k8s-versions', async() => { try { const versions = await k8smanager.kubeBackend.availableVersions; const cachedOnly = await k8smanager.kubeBackend.cachedVersionsOnly(); window.send('k8s-versions', versions.map(v => v.versionEntry), cachedOnly); } catch (ex) { console.error(`Error handling k8s-versions: ${ ex }`); window.send('k8s-versions', [], true); } }); ipcMainProxy.on('k8s-progress', () => { window.send('k8s-progress', k8smanager.progress); }); ipcMainProxy.handle('k8s-progress', () => { return k8smanager.progress; }); ipcMainProxy.handle('service-fetch', (_, namespace) => { return k8smanager.kubeBackend.listServices(namespace); }); ipcMainProxy.handle('service-forward', async(_, service, state) => { const namespace = service.namespace ?? 'default'; if (state) { const hostPort = service.listenPort ?? 0; await doForwardPort(namespace, service.name, service.port, hostPort); } else { await doCancelForward(namespace, service.name, service.port); } }); async function doForwardPort(namespace: string, service: string, k8sPort: string | number, hostPort: number) { return await k8smanager.kubeBackend.forwardPort(namespace, service, k8sPort, hostPort); } async function doCancelForward(namespace: string, service: string, k8sPort: string | number) { return await k8smanager.kubeBackend.cancelForward(namespace, service, k8sPort); } ipcMainProxy.on('k8s-integrations', async() => { mainEvents.emit('integration-update', await integrationManager.listIntegrations() ?? {}); }); ipcMainProxy.on('k8s-integration-set', (event, name, newState) => { writeSettings({ WSL: { integrations: { [name]: newState } } }); }); mainEvents.on('integration-update', (state) => { window.send('k8s-integrations', state); }); /** * Do a factory reset of the application. This will stop the currently running * cluster (if any), and delete all of its data. This will also remove any * rancher-desktop data, and restart the application. * * We need to write out rdctl output to a temporary directory because the logs directory * will get removed by the factory-reset. This code writes out (to background.log) where this file * exists, but if the user isn't tailing that file they won't see the message. */ async function doFactoryReset(keepSystemImages: boolean) { // Don't wait for this process to return -- the whole point is for us to not be running. const tmpdir = os.tmpdir(); const outfile = await fs.promises.open(path.join(tmpdir, 'rdctl-stdout.txt'), 'w'); const args = ['reset', '--factory', `--cache=${ (!keepSystemImages) ? 'true' : 'false' }`]; if (cfg.application.debug) { args.push('--verbose=true'); } const rdctl = spawn(path.join(paths.resources, os.platform(), 'bin', 'rdctl'), args, { detached: true, windowsHide: true, stdio: ['ignore', outfile.fd, outfile.fd], }); rdctl.unref(); console.debug(`If reset fails, the rdctl reset output files are in ${ tmpdir }`); } ipcMainProxy.on('factory-reset', (event, keepSystemImages) => { doFactoryReset(keepSystemImages); }); ipcMainProxy.on('show-logs', async(event) => { const error = await Electron.shell.openPath(paths.logs); if (error) { const browserWindow = Electron.BrowserWindow.fromWebContents(event.sender); const options: MessageBoxOptions = { message: error, type: 'error', title: `Error opening logs`, detail: `Please manually open ${ paths.logs }`, }; console.error(`Failed to open logs: ${ error }`); if (browserWindow) { await Electron.dialog.showMessageBox(browserWindow, options); } else { await Electron.dialog.showMessageBox(options); } } }); ipcMainProxy.on('diagnostics/run', () => { diagnostics.runChecks(); }); ipcMainProxy.on('get-app-version', async(event) => { event.reply('get-app-version', await getVersion()); }); ipcMainProxy.on('snapshot', (event, args) => { event.reply('snapshot', args); }); ipcMainProxy.on('snapshot/cancel', () => { window.send('snapshot/cancel'); }); ipcMainProxy.on('dialog/error', (event, args) => { window.getWindow(args.dialog)?.webContents.send('dialog/error', args); }); ipcMainProxy.on('dialog/close', (_event, args) => { window.getWindow(args.dialog)?.webContents.send('dialog/close', args); }); ipcMainProxy.handle('versions/macOs', () => { return getMacOsVersion(); }); ipcMainProxy.handle('host/isArm', () => { return process.arch === 'arm64'; }); ipcMainProxy.on('help/preferences/open-url', async() => { Help.preferences.openUrl(await getVersion()); }); ipcMainProxy.handle('show-message-box', (_event, options: Electron.MessageBoxOptions): Promise => { return window.showMessageBox(options, false); }); ipcMainProxy.handle('show-message-box-rd', async(_event, options: Electron.MessageBoxOptions, modal = false) => { const mainWindow = modal ? window.getWindow('main') : null; const dialog = window.openDialog( 'Dialog', { modal, parent: mainWindow || undefined, frame: true, title: options.title, height: 225, }); let response: any; dialog.webContents.on('ipc-message', (_event, channel, args) => { if (channel === 'dialog/mounted') { dialog.webContents.send('dialog/options', options); } if (channel === 'dialog/close') { response = args || { response: options.cancelId }; dialog.close(); } }); dialog.on('close', () => { if (response) { return; } response = { response: options.cancelId }; }); await (new Promise((resolve) => { dialog.on('closed', resolve); })); return response; }); ipcMainProxy.handle('show-snapshots-confirm-dialog', async( event, options: { window: Partial, format: SnapshotDialog }, ) => { const mainWindow = window.getWindow('main'); const dialog = window.openDialog( 'SnapshotsDialog', { title: 'Snapshots', modal: true, parent: mainWindow || undefined, frame: true, movable: true, height: 365, width: 640, }); if (os.platform() !== 'linux' && mainWindow && dialog) { window.centerDialog(mainWindow, dialog, 0, 50); } let response: any; dialog.webContents.on('ipc-message', (_event, channel, args) => { if (channel === 'dialog/mounted') { options.format.type = 'question'; dialog.webContents.send('dialog/options', options); } if (channel === 'dialog/close') { response = args || { response: options.window.cancelId }; dialog.close(); } }); dialog.on('close', () => { if (response) { return; } response = { response: options.window.cancelId }; }); await (new Promise((resolve) => { dialog.on('closed', resolve); })); return response; }); ipcMainProxy.handle('show-snapshots-blocking-dialog', async( event, options: { window: Partial, format: SnapshotDialog }, ) => { const dialogId = 'SnapshotsDialog'; if (window.getWindow(dialogId)) { return; } const mainWindow = window.getWindow('main'); const dialog = window.openDialog( dialogId, { modal: true, parent: mainWindow || undefined, frame: false, movable: false, height: 500, width: 700, }, false); const onMainWindowMove = () => { if (mainWindow && dialog) { window.centerDialog(mainWindow, dialog); } }; if (mainWindow && dialog) { if (os.platform() === 'linux') { /** Lock dialog position */ mainWindow.on('move', onMainWindowMove); } else { /** Center the dialog on main window, only for MacOs, Windows */ window.centerDialog(mainWindow, dialog, 0, 50); } } let response: any; dialog.webContents.on('ipc-message', (_event, channel, args) => { if (channel === 'dialog/mounted') { if (os.platform() !== 'darwin') { mainWindow?.webContents.send('window/blur', true); } options.format.type = 'operation'; dialog.webContents.send('dialog/options', options); event.sender.sendToFrame(event.frameId, 'dialog/mounted'); } if (channel === 'dialog/close') { response = args || { response: options.window.cancelId }; dialog.close(); } }); dialog.on('close', () => { if (os.platform() !== 'darwin') { mainWindow?.webContents.send('window/blur', false); } if (os.platform() === 'linux' && mainWindow) { mainWindow.off('move', onMainWindowMove); } if (response) { return; } response = { response: options.window.cancelId }; }); await (new Promise((resolve) => { dialog.on('closed', resolve); })); return response; }); function showErrorDialog(title: string, message: string, fatal?: boolean) { if (noModalDialogs) { console.log(`Fatal Error:\n${ title }\n\n${ message }`); } else { Electron.dialog.showErrorBox(title, message); } if (fatal) { Electron.app.quit(); } } async function handleFailure(payload: any) { let titlePart = 'Error Starting Rancher Desktop'; let message = 'There was an unknown error starting Rancher Desktop'; let secondaryMessage = ''; if (payload instanceof K8s.KubernetesError) { ({ name: titlePart, message } = payload); } else if (payload instanceof LockedFieldError) { showErrorDialog(titlePart, payload.message, true); return; } else if (payload instanceof DeploymentProfileError) { showErrorDialog('Failed to load the deployment profile', payload.message, true); return; } else if (payload instanceof FatalCommandLineOptionError) { showErrorDialog('Error in command-line options', payload.message, true); return; } else if (payload instanceof Error) { secondaryMessage = payload.toString(); } else if (typeof payload === 'number') { message = `Rancher Desktop was unable to start with the following exit code: ${ payload }`; } else if ('errorCode' in payload) { message = payload.message || message; titlePart = payload.context || titlePart; } console.log(`Rancher Desktop was unable to start:`, payload); try { // getFailureDetails is going to read from existing log files. // Wait 1 second before reading them to allow recent writes to appear in them. await util.promisify(setTimeout)(1_000); const failureDetails: K8s.FailureDetails = await k8smanager.getFailureDetails(payload); if (failureDetails) { if (noModalDialogs) { console.log(titlePart); console.log(secondaryMessage || message); // Since the log is to a file, we need to pretty-print it; otherwise the // message will just be `[Object object]`. console.log(JSON.stringify(failureDetails, undefined, 2)); gone = true; Electron.app.quit(); } else { await window.openKubernetesErrorMessageWindow(titlePart, secondaryMessage || message, failureDetails); } return; } } catch (e) { console.log(`Failed to get failure details: `, e); } if (noModalDialogs) { console.log(titlePart); console.log(message); gone = true; Electron.app.quit(); } else { showErrorDialog(titlePart, message, payload instanceof K8s.KubernetesError && payload.fatal); } } function doFullRestart(context: CommandWorkerInterface.CommandContext) { doK8sReset('fullRestart', context).catch((err: any) => { console.log(`Error restarting: ${ err }`); }); } async function getExtensionManager() { const getEM = (await import('@pkg/main/extensions/manager')).default; return await getEM(); } function newK8sManager() { const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; const mgr = K8sFactory(arch); mgr.on('state-changed', async(state: K8s.State) => { try { mainEvents.emit('k8s-check-state', mgr); if ([K8s.State.STARTED, K8s.State.DISABLED].includes(state)) { if (!cfg.kubernetes.version) { writeSettings({ kubernetes: { version: mgr.kubeBackend.version } }); } currentImageProcessor?.relayNamespaces(); if (enabledK8s) { await Steve.getInstance().start(); } } // Notify UI after Steve is ready, so the dashboard button is only enabled // when Steve can accept connections. window.send('k8s-check-state', state); if (state === K8s.State.STOPPING) { Steve.getInstance().stop(); } if (pendingRestartContext !== undefined && !backendIsBusy()) { // If we restart immediately the QEMU process in the VM doesn't always respond to a shutdown messages setTimeout(doFullRestart, 2_000, pendingRestartContext); pendingRestartContext = undefined; } } catch (ex) { console.error(ex); } }); mgr.on('progress', () => { window.send('k8s-progress', mgr.progress); }); mgr.on('show-notification', (notificationOptions: Electron.NotificationConstructorOptions) => { (new Electron.Notification(notificationOptions)).show(); }); mgr.kubeBackend.on('current-port-changed', (port: number) => { window.send('k8s-current-port', port); }); mgr.kubeBackend.on('service-changed', (services: K8s.ServiceEntry[]) => { console.debug(`service-changed: ${ JSON.stringify(services) }`); window.send('service-changed', services); }); mgr.kubeBackend.on('service-error', (service: K8s.ServiceEntry, errorMessage: string) => { console.debug(`service-error: ${ errorMessage }, ${ JSON.stringify(service) }`); window.send('service-error', service, errorMessage); }); mgr.kubeBackend.on('versions-updated', async() => { const versions = await mgr.kubeBackend.availableVersions; const cachedOnly = await mgr.kubeBackend.cachedVersionsOnly(); window.send('k8s-versions', versions.map(v => v.versionEntry), cachedOnly); }); return mgr; } function validateEarlySettings(cfg: settings.Settings, newSettings: RecursivePartial, lockedFields: settings.LockedSettingsType): void { // RD hasn't loaded the supported k8s versions yet, so have it defer actually checking the specified version. // If it can't find this version, it will silently move to the closest version. // We'd have to add more code to report that. // It isn't worth adding that code yet. It might never be needed. const newSettingsForValidation = _.omit(newSettings, 'kubernetes.version'); const [, errors] = new SettingsValidator().validateSettings(cfg, newSettingsForValidation, lockedFields); if (errors.length > 0) { throw new LockedFieldError(`Error in deployment profiles:\n${ errors.join('\n') }`); } } /** * Implement the methods that HttpCommandServer needs to service its requests. * These methods do two things: * 1. Verify the semantics of the parameters (the server just checks syntax). * 2. Provide a thin wrapper over existing functionality in this module. * Getters, on success, return status 200 and a string that may be JSON or simple. * Setters, on success, return status 202, possibly with a human-readable status note. * The `requestShutdown` method is a special case that never returns. */ class BackgroundCommandWorker implements CommandWorkerInterface { protected settingsValidator = new SettingsValidator(); /** * Use the settings validator to validate settings after doing any * initialization. */ protected async validateSettings(existingSettings: settings.Settings, newSettings: RecursivePartial) { let clearVersionsAfterTesting = false; if (newSettings.kubernetes?.version && this.settingsValidator.k8sVersions.length === 0) { // If we're starting up (by running `rdctl start...`) we probably haven't loaded all the k8s versions yet. // We don't want to verify if the proposed version makes sense (if it doesn't, we'll assign the default version later). // Here we just want to make sure that if we're changing the version to a different value from the current one, // the field isn't locked. let currentK8sVersions = (await k8smanager.kubeBackend.availableVersions).map(entry => entry.version.version); if (currentK8sVersions.length === 0) { clearVersionsAfterTesting = true; currentK8sVersions = [newSettings.kubernetes.version]; if (existingSettings.kubernetes.version) { currentK8sVersions.push(existingSettings.kubernetes.version); } } this.settingsValidator.k8sVersions = currentK8sVersions; } const result = this.settingsValidator.validateSettings(existingSettings, newSettings, settingsImpl.getLockedSettings()); if (clearVersionsAfterTesting) { this.settingsValidator.k8sVersions = []; } return result; } getSettings() { return jsonStringifyWithWhiteSpace(cfg); } getLockedSettings() { return jsonStringifyWithWhiteSpace(settingsImpl.getLockedSettings()); } getDiagnosticCategories(): string[] | undefined { return diagnostics.getCategoryNames(); } getDiagnosticIdsByCategory(category: string): string[] | undefined { return diagnostics.getIdsForCategory(category); } getDiagnosticChecks(category: string | null, checkID: string | null): Promise { return diagnostics.getChecks(category, checkID); } runDiagnosticChecks(): Promise { return diagnostics.runChecks(); } factoryReset(keepSystemImages: boolean) { doFactoryReset(keepSystemImages); } async k8sReset(context: CommandWorkerInterface.CommandContext, mode: 'fast' | 'wipe') { return await doK8sReset(mode, context); } async forwardPort(namespace: string, service: string, k8sPort: string | number, hostPort: number) { return await doForwardPort(namespace, service, k8sPort, hostPort); } async cancelForward(namespace: string, service: string, k8sPort: string | number) { return await doCancelForward(namespace, service, k8sPort); } /** * Execute the preference update for services that don't require a backend restart. */ async handleSettingsUpdate(newConfig: settings.Settings): Promise { const allowedImagesConf = '/usr/local/openresty/nginx/conf/allowed-images.conf'; const rcService = k8smanager.backend === 'wsl' ? 'wsl-service' : 'rc-service'; // Update image allow list patterns, just in case the backend doesn't need restarting // TODO: review why this block is needed at all if (cfg.containerEngine.allowedImages.enabled) { const allowListConf = BackendHelper.createAllowedImageListConf(cfg.containerEngine.allowedImages); await k8smanager.executor.writeFile(allowedImagesConf, allowListConf, 0o644); await k8smanager.executor.execCommand({ root: true }, rcService, '--ifstarted', 'rd-openresty', 'reload'); } else { await k8smanager.executor.execCommand({ root: true }, rcService, '--ifstarted', 'rd-openresty', 'stop'); await k8smanager.executor.execCommand({ root: true }, 'rm', '-f', allowedImagesConf); } await k8smanager.handleSettingsUpdate(newConfig); } /** * Check semantics of SET commands: * - verify that setting names are recognized, and validate provided values * - returns an array of two strings: * 1. a description of the status of the request, if it was valid * 2. a list of any errors in the request body. * @param specifiedNewSettings: a subset of the Settings object, containing the desired values * @returns [{string} description of final state if no error, {string} error message] */ async updateSettings(context: CommandWorkerInterface.CommandContext, specifiedNewSettings: RecursivePartial): Promise<[string, string]> { let errors: string[] = []; let needToUpdate = false; let newSettings: RecursivePartial = {}; try { newSettings = settingsImpl.migrateSpecifiedSettingsToCurrentVersion(specifiedNewSettings, false); [needToUpdate, errors] = await this.validateSettings(cfg, newSettings); } catch (ex: any) { errors.push(ex.message); } if (errors.length > 0) { return ['', `errors in attempt to update settings:\n${ errors.join('\n') }`]; } if (needToUpdate) { writeSettings(newSettings); // cfg is a global, and at this point newConfig has been merged into it :( window.send('settings-update', cfg); window.send('preferences/changed'); } else { // Obviously if there are no settings to update, there's no need to restart. return ['no changes necessary', '']; } // Update the values that doesn't need a restart of the backend. await this.handleSettingsUpdate(cfg); // Check if the newly applied preferences demands a restart of the backend. const restartReasons = await k8smanager.requiresRestartReasons(cfg); if (Object.keys(restartReasons).length === 0) { return ['settings updated; no restart required', '']; } // Trigger a restart of the backend (possibly delayed). if (!backendIsBusy()) { pendingRestartContext = undefined; setImmediate(doFullRestart, context); return ['reconfiguring Rancher Desktop to apply changes (this may take a while)', '']; } else { // Call doFullRestart once the UI is finished starting or stopping pendingRestartContext = context; return ['UI is currently busy, but will eventually be reconfigured to apply requested changes', '']; } } async proposeSettings(context: CommandWorkerInterface.CommandContext, newSettings: RecursivePartial): Promise<[string, string]> { const [, errors] = await this.validateSettings(cfg, newSettings); if (errors.length > 0) { return ['', `Errors in proposed settings:\n${ errors.join('\n') }`]; } const result = await k8smanager?.requiresRestartReasons(newSettings ?? {}) ?? {}; return [JSON.stringify(result), '']; } async requestShutdown() { httpCommandServer?.closeServer(); httpCredentialHelperServer.closeServer(); await k8smanager.stop(); Electron.app.quit(); } getTransientSettings() { return jsonStringifyWithWhiteSpace(TransientSettings.value); } updateTransientSettings( context: CommandWorkerInterface.CommandContext, newTransientSettings: RecursivePartial, ): Promise<[string, string]> { const [needToUpdate, errors] = this.settingsValidator.validateTransientSettings(TransientSettings.value, newTransientSettings); return Promise.resolve(((): [string, string] => { if (errors.length > 0) { return ['', `errors in attempt to update Transient Settings:\n${ errors.join('\n') }`]; } if (needToUpdate) { TransientSettings.update(newTransientSettings); return ['Updated Transient Settings', '']; } return ['No changes necessary', '']; })()); } async listExtensions() { const extensionManager = await getExtensionManager(); if (!extensionManager) { return undefined; } const extensions = await extensionManager.getInstalledExtensions(); const entries = await Promise.all(extensions.map(async x => [x.id, { version: x.version, metadata: await x.metadata, labels: await x.labels, }] as const)); return Object.fromEntries(entries); } async installExtension(image: string, state: 'install' | 'uninstall'): Promise<{ status: number, data?: any }> { const em = await getExtensionManager(); if (!em) { return { status: 503, data: 'Extension manager is not ready yet.' }; } const extension = await em.getExtension(image, { preferInstalled: state === 'uninstall' }); if (state === 'install') { console.debug(`Installing extension ${ image }...`); try { const { enabled, list } = cfg.application.extensions.allowed; if (await extension.install(enabled ? list : undefined)) { return { status: 201 }; } else { return { status: 204 }; } } catch (ex: any) { if (isExtensionError(ex)) { switch (ex.code) { case ExtensionErrorCode.INVALID_METADATA: return { status: 422, data: `The image ${ image } has invalid extension metadata` }; case ExtensionErrorCode.FILE_NOT_FOUND: return { status: 422, data: `The image ${ image } failed to install: ${ ex.message }` }; case ExtensionErrorCode.INSTALL_DENIED: return { status: 403, data: `The image ${ image } is not an allowed extension` }; } } throw ex; } finally { window.send('extensions/changed'); } } else { console.debug(`Uninstalling extension ${ image }...`); try { if (await extension.uninstall()) { window.send('ok:extensions/uninstall', image); return { status: 201 }; } else { return { status: 204 }; } } catch (ex: any) { if (isExtensionError(ex)) { switch (ex.code) { case ExtensionErrorCode.INVALID_METADATA: return { status: 422, data: `The image ${ image } has invalid extension metadata` }; } } throw ex; } finally { window.send('extensions/changed'); } } } async getBackendState(): Promise { const backendIsLocked = await readBackendLockFile(); return { vmState: k8smanager.state, locked: !!backendIsLocked, }; } async setBackendState(state: BackendState): Promise { await doesBackendLockExist(); switch (state.vmState) { case State.STARTED: cfg = settingsImpl.load(deploymentProfiles); mainEvents.emit('settings-update', cfg); setImmediate(() => { startBackend(); }); return; case State.STOPPED: setImmediate(() => { k8smanager.stop(); }); return; default: throw new Error(`invalid desired VM state "${ state.vmState }"`); } } async listSnapshots(context: CommandWorkerInterface.CommandContext) { return await Snapshots.list(); } async createSnapshot(context: CommandWorkerInterface.CommandContext, snapshot: Snapshot) { return await Snapshots.create(snapshot); } async restoreSnapshot(context: CommandWorkerInterface.CommandContext, name: string) { return await Snapshots.restore(name); } async cancelSnapshot() { return await Snapshots.cancel(); } async deleteSnapshot(context: CommandWorkerInterface.CommandContext, name: string) { return await Snapshots.delete(name); } } /** * Checks if Rancher Desktop was run as root. */ function isRoot(): boolean { const validPlatforms = ['linux', 'darwin']; if (!['linux', 'darwin'].includes(os.platform())) { throw new Error(`isRoot() can only be called on ${ validPlatforms }`); } return os.userInfo().uid === 0; } async function runRdctlSetup(newSettings: settings.Settings): Promise { // don't do anything with auto-start configuration if running in development if (isDevEnv) { return; } const rdctlPath = executable('rdctl'); const args = ['setup', `--auto-start=${ newSettings.application.autoStart }`]; await spawnFile(rdctlPath, args); } ================================================ FILE: bats/Makefile ================================================ .PHONY: all all: ./bats-core/bin/bats --show-output-of-passing-tests ./tests/*/ .PHONY: containers containers: ./bats-core/bin/bats --show-output-of-passing-tests ./tests/containers/ # https://www.shellcheck.net/wiki/SC1091 -- Not following: xxx was not specified as input (see shellcheck -x) # https://www.shellcheck.net/wiki/SC2034 -- xxx appears unused. Verify use (or export if used externally) # https://www.shellcheck.net/wiki/SC2154 -- xxx is referenced but not assigned # https://www.shellcheck.net/wiki/SC2218 -- This function is only defined later. Move the definition up. SC_EXCLUDES ?= SC1091,SC2034,SC2154,SC2218 .PHONY: lint lint: find tests -name '*.bash' | xargs ./scripts/bats-lint.pl find tests -name '*.bats' | xargs ./scripts/bats-lint.pl find tests -name '*.bash' | xargs shellcheck -s bats -e $(SC_EXCLUDES) find tests -name '*.bats' | xargs shellcheck -s bats -e $(SC_EXCLUDES) find scripts -name '*.sh' | xargs shellcheck -s bash -e $(SC_EXCLUDES) find tests -name '*.bash' | xargs shfmt --diff find tests -name '*.bats' | xargs shfmt --diff find scripts -name '*.sh' | xargs shfmt --diff DEPS = bin/darwin/jq bin/linux/jq # Package bats tests with bats itself and dependencies into a single tarball for distribution bats.tar.gz: $(DEPS) git submodule update --init --recursive tar cvfz "$@" --exclude-vcs --exclude "*/test/*" --exclude "*/docs/*" -- * JQ_VERSION ?= 1.7.1 JQ_URL=https://github.com/stedolan/jq/releases/download/jq-$(JQ_VERSION) bin/darwin/jq: mkdir -p bin/darwin wget --no-verbose $(JQ_URL)/jq-osx-amd64 -O $@ chmod +x $@ bin/linux/jq: mkdir -p bin/linux wget --no-verbose $(JQ_URL)/jq-linux64 -O $@ chmod +x $@ .PHONY: clean clean: rm -f $(DEPS) rm -f bats.tar.gz ================================================ FILE: bats/README.md ================================================ ## Overview BATS is a testing framework for Bash shell scripts that provides supporting libraries and helpers for customizable test automation. ## Setup It's important to have a Rancher Desktop CI or release build installed and running with no errors before executing the BATS tests. ### On Windows: Clone the Git repository of Rancher Desktop, whether directly inside a WSl distro or on the host Win32. If the repository will be cloned on Win32, prior to cloning it, it's important to set up the Git configuration by running the following commands: ```powershell git config --global core.eol lf git config --global core.autocrlf false ``` Note that changing `crlf` settings is not needed when you clone it inside a WSL distro. Regardless of the repository location, the BATS tests can be executed ONLY from inside a WSL distribution. So, if the repository is cloned on Win32, the repository can be located within a WSL distro from /mnt/c, as it represents the `C:` drive on Windows. ### On Linux: ImageMagick is required to take screenshots on failure. ### All platforms: From the root directory of the Git repository, run the following commands to install BATS and its helper libraries into the BATS test directory: ```sh git submodule update --init ``` ## Running BATS To run the BATS test, specify the path to BATS executable from bats-core and run the following commands: To run a specific test set from a bats file: ```sh cd bats ./bats-core/bin/bats tests/registry/creds.bats ``` To run all BATS tests: ```sh cd bats ./bats-core/bin/bats tests/*/ ``` To run the BATS test, specifying some of Rancher Desktop's configuration, run the following commands: ```sh cd bats RD_CONTAINER_RUNTIME=moby RD_USE_IMAGE_ALLOW_LIST=false ./bats-core/bin/bats tests/registry/creds.bats ``` There is an experimental subset of BATS tests that pass with an under-construction openSUSE based distribution; that can be selected via the `opensuse` tag: ```sh cd bats ./bats-core/bin/bats --filter-tags opensuse tests/*/ ``` ### On Windows: BATS must be executed from within a WSL distribution. (You have to cd into `/mnt/c/REPOSITORY_LOCATION` from your unix shell.) To test the Windows-based tools, set `RD_USE_WINDOWS_EXE` to `true` before running. ### RD_LOCATION By default bats will use Rancher Desktop installed in a "system" location. If that doesn't exists, it will try a "user" location, followed by the local "dist" directory inside the local git directory. The final option if none of the above apply is to use "dev", which uses `yarn dev`. On Linux there is no "user" location. You can explicitly request a specific install location by setting `RD_LOCATION` to `system`, `user`, `dist`, or `dev`: ``` cd bats RD_LOCATION=dist ./bats-core/bin/bats ... ``` ### RD_NO_MODAL_DIALOGS By default, bats tests are run with the `--no-modal-dialogs` option so fatal errors are written to `background.log`, rather than appearing in a blocking modal dialog box. If you *want* those dialog boxes, you can specify ``` cd bats RD_NO_MODAL_DIALOGS=false ./bats-core/bin/bats ... ``` The default value for this environment variable is `true`. ## Writing BATS Tests 1. Add BATS test by creating files with `.bats` extension under `./bats/tests/FOLDER_NAME` 2. A Bats test file is a Bash script with special syntax for defining test cases. BATS syntax and libraries for defining test hooks, writing assertions and treating output can be accessed via BATS [documentation](https://bats-core.readthedocs.io/en/stable/): - [bats-core](https://github.com/rancher-sandbox/bats-core) - [bats-assert](https://github.com/rancher-sandbox/bats-assert) - [bats-file](https://github.com/rancher-sandbox/bats-file) - [bats-support](https://github.com/rancher-sandbox/bats-support) ## BATS linting After finishing to develop a BATS test suite, you can locally verify the syntax and formatting feedback by linting prior to submitting a PR, following the instructions: 1. Make sure to have installed `shellcheck` and `shfmt`. On macOS: - Assuming you have Homebrew: ```sh brew install shfmt shellcheck ``` - If you have Go installed, you can also install `shfmt` by running: ```sh go install mvdan.cc/sh/v3/cmd/shfmt@v3.6.0 ``` On Linux: - The simplest way to install ShellCheck locally is through your package managers such as `apt/apt-get/yum`. Run commands as per your distro. ``` sudo apt install shellcheck ``` - `shfmt` is available as a snap application. If your distribution has snap installed, you can install `shfmt` using the command: ```sh sudo snap install shfmt ``` The other way to install `shfmt` is by using the following one-liner command: ```sh curl -sS https://webinstall.dev/shfmt | bash ``` If you have Go installed, you can also install `shfmt` by running: ```sh go install mvdan.cc/sh/v3/cmd/shfmt@v3.6.0 ``` On Windows: - The simplest way to install `shellcheck` locally is: Via chocolatey: ```powershell choco install shellcheck ``` Via scoop: ```powershell scoop install shellcheck ``` - If you have Go installed, you can install `shfmt` by running: ```powershell go install mvdan.cc/sh/v3/cmd/shfmt@v3.6.0 ``` 2. Get the syntax and formatting feedback for BATS linting by running from the root directory of the Git repository: ```sh make -C bats lint ``` 3. Please, make sure to fix the highlighted linting errors prior to submitting a PR. You can automatically apply formatting changes suggested by `shfmt` by running the following command: ```sh shfmt -w ./bats/tests/containers/factory-reset.bats ``` ## Running BATS in CI We also run BATS in CI via [GitHub Actions]; at the time of writing, we do not yet run them automatically due to failing tests. There are many optional fields that may be set when triggering a run manually: [GitHub Actions]: https://github.com/rancher-sandbox/rancher-desktop/actions/workflows/bats.yaml
Input Description
owner, repo Forms the GitHub repository to test; defaults to the current repository.
branch The branch to test; defaults to the current branch.
tests The list of tests, as a whitespace-separated glob expression relative to the tests directory. The .bats suffix may be omitted on test files.
platforms A space-separated list of platforms to test on; defaults to everything, and items may be removed to reduce coverage.
engines A space-separated list of container engines to test on; defaults to everything, and items may be removed to reduce coverage.
package-id A specific GitHub run ID for the package action to test. This allows to test code from runs where it failed to build on platforms that don't need to be tested, or in-process runs as long as the relevant platforms have already completed.
### Debugging BATS in CI Sometimes we may need to drill down why a test is failing in CI (for example, when the same test doesn't fail locally). Some things might be helpful: - Logs for failing runs can be downloaded by clicking on the :file_folder: icon in the summary table at the bottom of the run. - If changes to the application or BATS tests are required, a new [package action] run will need to be manually triggered. In that case, setting `sign` to `false` in that run will speed it up by a few minutes, by skipping the check for properly signed installers — that can be dealt with when the actual PR is made. - When focusing on a particular failing platform, it may be possible to shave off a few minutes by setting the `package-id` field (see above) when starting the BATS run; this lets you start the run once the platform you're interested in has completed packaging, without waiting for other platforms. This should be set to the number after `…/actions/runs/` in the URL. - When testing, it is a good idea to [fork the repository] and run the tests there; this lets you have your own set of GitHub runner quota (which means not waiting for PRs other people create). It is not necessary to set `owner` and `repo` fields when running the BATS action (because it defaults to the repository the action is running on). You will, however, need to run the [package action] at least once in your fork. - It is much faster to specify `tests`, `platforms`, and `engines` to limit runs to only the tests you care about; the full run takes somewhere over two hours total, even spread out over multiple parallel jobs. [package action]: https://github.com/rancher-sandbox/rancher-desktop/actions/workflows/package.yaml [fork the repository]: https://github.com/rancher-sandbox/rancher-desktop/fork ================================================ FILE: bats/scripts/bats-lint.pl ================================================ #!/usr/bin/env perl # This script checks a BATS script to make sure every `run` or `try` call is # followed by a call to `assert` or `refute`, or a reference to `$output` or # `$status`. `assert` may be a variable reference like `${assert}`. # # The `run` or `try` call may be followed by blank lines or `if ...` statements # before the assert/refute becomes required. use strict; use warnings; my $problems = 0; my $run; my $continue; while (<>) { if ($ARGV =~ /\.bats$/) { # bats files should not override the global setup and teardown functions. # They should define local_* variants instead, which will be called from # the global versions. if (/^((setup|teardown)\w*)\(/) { print "$ARGV:$.: Don't define $1(); define local_$1() instead\n"; $problems++; } if (/\b run \b .* \b load_var \b/x) { print "$ARGV:$.: Running load_var in a subshell (via run) does not work\n"; $problems++; } } # The semver comparison functions take arguments that are valid semver; # catch uses of it with invalid versions, like '1.2' instead of '1.2.3'. if (/ (semver_(?:n?eq|[lg]te?)) # Semver comparison function [^#\n]* # Eat any number of characters before new line or comment (?/dev/null; then echo "This script requires the 'skopeo' utility to be installed" exit 1 fi source "$(dirname "${BASH_SOURCE[0]}")/../tests/helpers/images.bash" # IMAGES is setup by ../tests/helpers/images.bash # shellcheck disable=SC2153 for IMAGE in "${IMAGES[@]}"; do echo "===== Copying $IMAGE =====" skopeo copy --all "docker://$IMAGE" "docker://$GHCR_REPO/$IMAGE" done ================================================ FILE: bats/tests/compose/compose.bats ================================================ # bats file_tags=opensuse load '../helpers/load' local_setup() { TESTDATA_DIR="${PATH_BATS_ROOT}/tests/compose/testdata/" TESTDATA_DIR_HOST=$(host_path "$TESTDATA_DIR") } @test 'factory reset' { factory_reset } @test 'start container engine' { start_container_engine wait_for_container_engine } @test 'compose up' { ctrctl compose --project-directory "$TESTDATA_DIR_HOST" build \ --build-arg IMAGE_NGINX="$IMAGE_NGINX" \ --build-arg IMAGE_PYTHON="$IMAGE_PYTHON_3_9_SLIM" ctrctl compose --project-directory "$TESTDATA_DIR_HOST" up -d --no-build } verify_running_container() { try --max 9 --delay 10 curl --silent --show-error "$1" assert_success assert_output --partial "$2" } @test 'verify app bound to localhost' { verify_running_container "http://localhost:8080" "Welcome to nginx!" skip_unless_host_ip run curl --verbose --head "http://${HOST_IP}:8080" assert_output --partial "curl: (7) Failed to connect" } @test 'verify app bound to wildcard IP' { local expected_output="Hello World!" verify_running_container "http://localhost:8000" "$expected_output" skip_unless_host_ip verify_running_container "http://${HOST_IP}:8000" "$expected_output" } @test 'verify connectivity via host.docker.internal' { local expected_output="Hello World!" verify_running_container "http://localhost:8080/app" "$expected_output" } @test 'compose down' { run ctrctl compose --project-directory "$TESTDATA_DIR_HOST" down assert_success } ================================================ FILE: bats/tests/compose/testdata/Dockerfile.nginx ================================================ ARG IMAGE_NGINX FROM ${IMAGE_NGINX} ================================================ FILE: bats/tests/compose/testdata/app/Dockerfile ================================================ ARG IMAGE_PYTHON=python:3.9-slim FROM ${IMAGE_PYTHON} WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["python", "app.py"] ================================================ FILE: bats/tests/compose/testdata/app/app.py ================================================ from flask import Flask app = Flask(__name__) @app.route('/') def hello(): return "Hello World!" if __name__ == '__main__': app.run(host='0.0.0.0', port=8000) ================================================ FILE: bats/tests/compose/testdata/app/requirements.txt ================================================ flask ================================================ FILE: bats/tests/compose/testdata/compose.yaml ================================================ services: nginx: container_name: nginx build: args: - IMAGE_NGINX dockerfile: Dockerfile.nginx volumes: - ./nginx.conf:/etc/nginx/nginx.conf ports: - '127.0.0.1:8080:80' web: build: args: - IMAGE_PYTHON context: app # flask requires SIGINT to stop gracefully # (default stop signal from Compose is SIGTERM) stop_signal: SIGINT ports: - '8000:8000' ================================================ FILE: bats/tests/compose/testdata/nginx.conf ================================================ worker_processes 1; error_log stderr info; events { worker_connections 1024; } http { include mime.types; sendfile on; proxy_read_timeout 5s; server { listen 80; server_name localhost; # Serve the default nginx welcome page location / { root /usr/share/nginx/html; index index.html; } # Proxy requests to /app to the backend service location /app { proxy_pass http://host.docker.internal:8000/; } } } ================================================ FILE: bats/tests/containers/allowed-images.bats ================================================ load '../helpers/load' RD_USE_IMAGE_ALLOW_LIST=true @test 'start' { factory_reset start_kubernetes wait_for_container_engine wait_for_kubelet } @test 'update the list of patterns first time' { update_allowed_patterns true "$IMAGE_NGINX" "$IMAGE_BUSYBOX" "$IMAGE_PYTHON" } @test 'verify pull nginx succeeds' { ctrctl pull --quiet "$IMAGE_NGINX" } @test 'verify pull busybox succeeds' { ctrctl pull --quiet "$IMAGE_BUSYBOX" } @test 'verify pull python succeeds' { ctrctl pull --quiet "$IMAGE_PYTHON" } assert_pull_fails() { run ctrctl pull "$1" assert_failure assert_output --regexp "(UNAUTHORIZED|Forbidden)" } @test 'verify pull ruby fails' { try --max 9 --delay 10 assert_pull_fails "$IMAGE_RUBY" } @test 'drop python from the allowed-image list, add ruby' { update_allowed_patterns true "$IMAGE_NGINX" "$IMAGE_BUSYBOX" "$IMAGE_RUBY" } @test 'clear images' { for image in IMAGE_NGINX IMAGE_BUSYBOX IMAGE_PYTHON; do ctrctl rmi "${!image}" done } @test 'verify pull python fails' { try --max 9 --delay 10 assert_pull_fails "$IMAGE_PYTHON" } @test 'verify pull ruby succeeds' { # when using VZ and when traefik is enabled, then pulling the image does not always succeed on the first attempt try --max 9 --delay 10 ctrctl pull --quiet "$IMAGE_RUBY" } @test 'clear all patterns' { update_allowed_patterns true } @test 'can run kubectl' { kubectl run nginx --image="${IMAGE_NGINX}" --port=8080 } verify_no_nginx() { run kubectl get pods assert_success assert_output --partial "ImagePullBackOff" } @test 'but fails to stand up a pod for forbidden image' { try --max 18 --delay 10 verify_no_nginx } @test 'set patterns with the allowed list disabled' { update_allowed_patterns false "$IMAGE_NGINX" "$IMAGE_BUSYBOX" "$IMAGE_RUBY" } @test 'verify pull python succeeds because allowedImages filter is disabled' { # when using VZ and when traefik is enabled, then pulling the image does not always succeed on the first attempt try --max 9 --delay 10 ctrctl pull --quiet "$IMAGE_PYTHON" } ================================================ FILE: bats/tests/containers/auto-start.bats ================================================ load '../helpers/load' @test 'factory reset' { factory_reset } @test 'Start up Rancher Desktop' { start_application } @test 'Verify that initial Behavior is all set to false' { run get_setting '.application.autoStart' assert_success assert_output false run get_setting '.application.startInBackground' assert_success assert_output false run get_setting '.application.window.quitOnClose' assert_success assert_output false run get_setting '.application.hideNotificationIcon' assert_success assert_output false } @test 'Enable auto start' { rdctl set --application.auto-start=true run get_setting '.application.autoStart' assert_success assert_output true } @test 'Verify that the auto-start config is created' { if using_dev_mode; then skip "Autostart prefs don't work in dev mode" fi if is_linux; then assert_file_exists "${XDG_CONFIG_HOME:-$HOME/.config}/autostart/rancher-desktop.desktop" fi if is_macos; then assert_file_exists "$HOME/Library/LaunchAgents/io.rancherdesktop.autostart.plist" fi if is_windows; then run powershell.exe -c "reg query HKCU\Software\Microsoft\Windows\CurrentVersion\Run /v RancherDesktop" assert_success assert_line --index 2 --partial "\Rancher Desktop.exe" fi } @test 'Disable auto start' { rdctl set --application.auto-start=false run get_setting '.application.autoStart' assert_success assert_output false } @test 'Verify that the auto-start config is removed' { if using_dev_mode; then skip "Autostart prefs don't work in dev mode" fi if is_linux; then assert_file_not_exists "${XDG_CONFIG_HOME:-$HOME/.config}/autostart/rancher-desktop.desktop" fi if is_macos; then assert_file_not_exists "$HOME/Library/LaunchAgents/io.rancherdesktop.autostart.plist" fi if is_windows; then run powershell.exe -c "reg query HKCU\Software\Microsoft\Windows\CurrentVersion\Run /v RancherDesktop" assert_failure assert_output --partial "The system was unable to find the specified registry" fi } @test 'Enable quit-on-close' { rdctl set --application.window.quit-on-close=true run get_setting '.application.window.quitOnClose' assert_success assert_output true } @test 'Disable quit-on-close' { rdctl set --application.window.quit-on-close=false run get_setting '.application.window.quitOnClose' assert_success assert_output false } @test 'Enable start-in-background' { rdctl set --application.start-in-background=true run get_setting '.application.startInBackground' assert_success assert_output true } @test 'Disable start-in-background' { rdctl set --application.start-in-background=false run get_setting '.application.startInBackground' assert_success assert_output false } @test 'Enable hide-notification-icon' { rdctl set --application.hide-notification-icon=true run get_setting '.application.hideNotificationIcon' assert_success assert_output true } @test 'Disable hide-notification-icon' { rdctl set --application.hide-notification-icon=false run get_setting '.application.hideNotificationIcon' assert_success assert_output false } ================================================ FILE: bats/tests/containers/catch-duplicate-api-patterns.bats ================================================ load '../helpers/load' RD_USE_IMAGE_ALLOW_LIST=true @test 'catch attempts to add duplicate patterns via the API with enabled on' { factory_reset start_kubernetes wait_for_kubelet wait_for_container_engine run update_allowed_patterns true "$IMAGE_NGINX" "$IMAGE_BUSYBOX" "$IMAGE_RUBY" "$IMAGE_BUSYBOX" assert_failure assert_output --partial $"field \"containerEngine.allowedImages.patterns\" has duplicate entries: \"$IMAGE_BUSYBOX\"" } @test 'catch attempts to add duplicate patterns via the API with enabled off' { run update_allowed_patterns false "$IMAGE_NGINX" "$IMAGE_BUSYBOX" "$IMAGE_RUBY" "$IMAGE_BUSYBOX" assert_failure assert_output --partial $"field \"containerEngine.allowedImages.patterns\" has duplicate entries: \"$IMAGE_BUSYBOX\"" } ================================================ FILE: bats/tests/containers/docker-buildx-python3-uname.bats ================================================ load '../helpers/load' local_setup() { if ! using_docker; then skip "This test only applied to the moby container engine" fi TEMP=/tmp if is_windows; then # We need to use a directory that exists on the Win32 filesystem # so the docker clients can correctly map the bind mounts. # We can use host_path() on these paths because they will exist # both here and in the rancher-desktop distro. TEMP="$(wslpath_from_win32_env TEMP)" fi BUILDX_BUILDER=rd_bats_builder WORK_DIR="$TEMP/$BUILDX_BUILDER" BUILDX_INSTANCE=amd64builder } @test 'start' { factory_reset start_container_engine wait_for_container_engine # Do any cleanup from previous runs run docker buildx rm "$BUILDX_INSTANCE" assert_nothing rm -fr "$WORK_DIR" } @test 'create the source directory to work in' { mkdir -p "$WORK_DIR" cat >"${WORK_DIR}/Dockerfile" <<'EOF' FROM registry.access.redhat.com/ubi8/python-39:1-57 RUN python3 -m pip install tornado CMD echo "Running on $(uname -m)" EOF } @test 'build the container' { docker buildx create --name "$BUILDX_INSTANCE" docker buildx use "$BUILDX_INSTANCE" cd "$WORK_DIR" docker buildx build -t testbuild:00 --platform linux/amd64 --load . run docker run --platform linux/amd64 testbuild:00 assert_success assert_output "Running on x86_64" } ================================================ FILE: bats/tests/containers/factory-reset-containerd-shims.bats ================================================ load '../helpers/load' BOGUS_SHIM="${PATH_CONTAINERD_SHIMS}/containerd-shim-bogus-v1" local_setup_file() { RD_USE_RAMDISK=false # interferes with deleting $PATH_APP_HOME delete_all_snapshots rm -rf "$PATH_CONTAINERD_SHIMS" } local_teardown_file() { rm -rf "$PATH_CONTAINERD_SHIMS" } @test 'factory reset' { # On Windows the cache directory is under PATH_APP_HOME. factory_reset --cache assert_not_exists "$PATH_APP_HOME" } @test 'factory reset will not remove any shims' { assert_not_exists "$PATH_CONTAINERD_SHIMS" create_file "$BOGUS_SHIM" <<<'' factory_reset assert_exists "$BOGUS_SHIM" assert_exists "$PATH_APP_HOME" } @test 'factory reset will remove empty shim directory' { rm "$BOGUS_SHIM" factory_reset assert_not_exists "$PATH_CONTAINERD_SHIMS" assert_not_exists "$PATH_APP_HOME" } ================================================ FILE: bats/tests/containers/factory-reset-snapshots.bats ================================================ load '../helpers/load' local_setup_file() { RD_USE_RAMDISK=false # interferes with deleting $PATH_APP_HOME } @test 'factory reset' { delete_all_snapshots rm -rf "$PATH_CONTAINERD_SHIMS" # On Windows the cache directory is under PATH_APP_HOME. factory_reset --cache } @test 'Start up Rancher Desktop with a snapshots subdirectory' { start_container_engine wait_for_container_engine wait_for_backend } @test "Verify the snapshot dir isn't deleted on factory-reset" { rdctl shutdown rdctl snapshot create shortlived-snapshot factory_reset --cache assert_not_exists "$PATH_APP_HOME/rd-engine.json" assert_exists "$PATH_SNAPSHOTS" run ls -A "$PATH_SNAPSHOTS" assert_output } @test 'Verify factory-reset deletes an empty snapshots directory' { rdctl snapshot delete shortlived-snapshot factory_reset --cache assert_not_exists "$PATH_APP_HOME" } ================================================ FILE: bats/tests/containers/factory-reset.bats ================================================ load '../helpers/load' local_setup_file() { RD_USE_RAMDISK=false # interferes with deleting $PATH_APP_HOME } @test 'factory reset' { factory_reset } @test 'Start up Rancher Desktop' { start_application } @test 'Verify that the expected directories were created' { before check_directories } @test 'Verify that docker symlinks were created' { before check_docker_symlinks } @test 'Verify that path management was set' { before check_path } @test 'Verify that rancher desktop context was created' { before check_rd_context } @test 'Verify that lima VM was created' { before check_lima } @test 'Verify that WSL distributions were created' { before check_WSL } @test 'Shutdown Rancher Desktop' { rdctl shutdown } @test 'factory-reset when Rancher Desktop is not running' { touch_updater_longhorn rdctl_factory_reset --verbose } @test 'Verify that the expected directories were deleted' { check_directories } @test 'Verify that docker symlinks were deleted' { check_docker_symlinks } @test 'Verify that path management was unset' { check_path } @test 'Verify that rancher desktop context was deleted' { check_rd_context } @test 'Verify that lima VM was deleted' { check_lima } @test 'Verify that WSL distributions were deleted' { check_WSL } @test 'Verify updater-longhorn.json was deleted' { check_updater_longhorn_gone } @test 'Start Rancher Desktop 2' { start_application } @test 'factory reset - keep cached k8s images' { rdctl_factory_reset --remove-kubernetes-cache=false --verbose } @test 'Verify that the expected directories were deleted 2' { check_directories } @test 'Verify that docker symlinks were deleted 2' { check_docker_symlinks } @test 'Verify that path management was unset 2' { check_path } @test 'Verify that rancher desktop context was deleted 2' { check_rd_context } @test 'Verify that lima VM was deleted 2' { check_lima } @test 'Verify that WSL distributions were deleted 2' { check_WSL } @test 'Verify updater-longhorn.json was deleted 2' { check_updater_longhorn_gone } @test 'Start Rancher Desktop 3' { start_application } @test 'factory reset - delete cached k8s images' { rdctl_factory_reset --remove-kubernetes-cache=true --verbose } @test 'Verify that the expected directories were deleted 3' { check_directories } @test 'Verify that docker symlinks were deleted 3' { check_docker_symlinks } @test 'Verify that path management was unset 3' { check_path } @test 'Verify that rancher desktop context was deleted 3' { check_rd_context } @test 'Verify that lima VM was deleted 3' { check_lima } @test 'Verify that WSL distributions were deleted 3' { check_WSL } @test 'Verify updater-longhorn.json was deleted when cache was retained' { check_updater_longhorn_gone } rdctl_factory_reset() { capture_logs rdctl factory-reset "$@" if [[ $1 == "--remove-kubernetes-cache=true" ]]; then assert_not_exist "$PATH_CACHE" else assert_exists "$PATH_CACHE" fi } check_directories() { # Check if all expected directories are created after starting application/ are deleted after a factory reset delete_dir=("$PATH_LOGS" "$PATH_APP_HOME/credential-server.json" "$PATH_APP_HOME/rd-engine.json") if is_unix; then # On Windows "$PATH_CONFIG" == "$PATH_APP_HOME" delete_dir+=("$HOME/.rd" "$LIMA_HOME" "$PATH_CONFIG") # We can't make any general assertion on AppHome/snapshots - we don't know if it was created or not # So just assert on the other members of AppHome # TODO on macOS (not implemented by `rdctl factory-reset`) # ~/Library/Saved Application State/io.rancherdesktop.app.savedState # this one only exists after an update has been downloaded # ~/Library/Application Support/Caches/rancher-desktop-updater fi if is_windows; then # On Windows $PATH_CONFIG is the same as $PATH_APP_HOME delete_dir+=("$PATH_CONFIG_FILE" "$PATH_DISTRO" "$PATH_DISTRO_DATA") # TODO: What about $PATH_APP_HOME/vtunnel-config.yaml ? fi for dir in "${delete_dir[@]}"; do echo "# $assert that $dir does not exist" 1>&3 "${assert}_not_exists" "$dir" done } check_docker_symlinks() { skip_on_windows # Check if docker-X symlinks were deleted for dfile in docker-buildx docker-compose; do run readlink "$HOME/.docker/cli-plugins/$dfile" "${refute}_output" "$HOME/.rd/bin/$dfile" done } check_path() { skip_on_windows # Check if ./rd/bin was removed from the path # TODO add check for config.fish env_profiles=( "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.cshrc" "$HOME/.tcshrc" ) for candidate in .bash_profile .bash_login .profile; do if [ -e "$HOME/$candidate" ]; then env_profiles+=("$HOME/$candidate") # Only the first candidate that exists will be modified if [ "${assert}" = "refute" ]; then break fi fi done for profile in "${env_profiles[@]}"; do echo "$assert that $profile does not add ~/.rd/bin to the PATH" # cshrc: setenv PATH "/Users/jan/.rd/bin"\:"$PATH" # posix: export PATH="/Users/jan/.rd/bin:$PATH" run grep "PATH.\"$HOME/.rd/bin" "$profile" "${assert}_failure" done } check_rd_context() { skip_on_windows # Check if the rancher-desktop docker context has been removed if using_docker; then echo "$assert that the docker context rancher-desktop does not exist" run grep -r rancher-desktop "$HOME/.docker/contexts/meta" "${assert}_failure" fi } check_lima() { skip_on_windows # Check that the VM has been removed and no longer exists. run limactl ls "${assert}_output" --regexp "No instance found|no such file or directory" } check_WSL() { skip_on_unix # Check if rancher-desktop WSL distros are deleted on Windows run powershell.exe -c "wsl.exe --list" "${refute}_output" --partial "rancher-desktop-data" "${refute}_output" --partial "rancher-desktop" } check_updater_longhorn_gone() { assert_not_exists "$PATH_CACHE/updater-longhorn.json" } touch_updater_longhorn() { touch "$PATH_CACHE/updater-longhorn.json" } ================================================ FILE: bats/tests/containers/host-connectivity.bats ================================================ # bats file_tags=opensuse load '../helpers/load' @test 'factory reset' { factory_reset } @test 'start container engine' { start_container_engine wait_for_container_engine } verify_host_connectivity() { run ctrctl run --rm "$IMAGE_BUSYBOX" timeout -s INT 10 ping -c 5 "$1" assert_success assert_output --partial "5 packets transmitted, 5 packets received, 0% packet loss" } @test 'ping host.docker.internal from a container' { verify_host_connectivity "host.docker.internal" } @test 'ping host.rancher-desktop.internal from a container' { verify_host_connectivity "host.rancher-desktop.internal" } ================================================ FILE: bats/tests/containers/host-network-ports.bats ================================================ # bats file_tags=opensuse load '../helpers/load' LOCALHOST="127.0.0.1" local_setup() { if ! is_windows; then skip "The test doesn't work on non-Windows platforms" fi } @test 'factory reset' { factory_reset } @test 'start container engine' { start_container_engine wait_for_container_engine } run_container_with_host_network_driver() { local image="python:slim" ctrctl pull --quiet "$image" ctrctl run -d --network=host --restart=no "$image" "$@" } verify_container_port() { run try --max 9 --delay 10 curl --insecure --verbose --show-error "$@" assert_success assert_output --partial 'Directory listing for' } @test 'process is bound to 0.0.0.0 using host network driver' { local container_port="8010" run_container_with_host_network_driver python -m http.server "$container_port" verify_container_port "http://$LOCALHOST:$container_port" skip_unless_host_ip verify_container_port "http://${HOST_IP}:$container_port" } @test 'process is bound to 127.0.0.1 using host network driver' { local container_port="8016" run_container_with_host_network_driver python -m http.server $container_port --bind "$LOCALHOST" verify_container_port "http://$LOCALHOST:$container_port" skip_unless_host_ip run curl --verbose --head "http://${HOST_IP}:$container_port" assert_output --partial "curl: (7) Failed to connect" } ================================================ FILE: bats/tests/containers/init.bats ================================================ # verify that running a container with --init is working # bats file_tags=opensuse load '../helpers/load' @test 'factory reset' { factory_reset } @test 'start container engine' { start_container_engine wait_for_container_engine } @test 'run container with init process' { # BUG BUG BUG # The following `ctrctl run` command includes the `-i` option to work around a docker # bug on Windows: https://github.com/rancher-sandbox/rancher-desktop/issues/3239 # It is harmless in other configurations, but should not be required here. # BUG BUG BUG run ctrctl run -i --rm --init "$IMAGE_BUSYBOX" ps -ef assert_success # PID USER TIME COMMAND # 1 root 0:00 /sbin/docker-init -- ps -ef # 1 root 0:00 /sbin/tini -- ps -ef assert_line --regexp '^ +1 .+ /sbin/(docker-init|tini) -- ps -ef$' } ================================================ FILE: bats/tests/containers/platform.bats ================================================ load '../helpers/load' @test 'factory reset' { factory_reset } @test 'start container engine' { start_container_engine wait_for_container_engine } check_uname() { local platform="linux/$1" local cpu="$2" # Pull container separately because `ctrctl run` doesn't have a --quiet option ctrctl pull --quiet --platform "$platform" "$IMAGE_BUSYBOX" # BUG BUG BUG # Adding -i option to work around a bug with the Linux docker CLI in WSL # https://github.com/rancher-sandbox/rancher-desktop/issues/3239 # BUG BUG BUG run ctrctl run -i --platform "$platform" "$IMAGE_BUSYBOX" uname -m if is_true "${assert_success:-true}"; then assert_success assert_output "$cpu" fi } @test 'deploy amd64 container' { check_uname amd64 x86_64 } @test 'deploy arm64 container' { if is_windows; then # TODO why don't we do this? skip "aarch64 emulation is not included in the Windows version" fi check_uname arm64 aarch64 } @test 'uninstall s390x emulator' { if is_windows; then # On WSL the emulator might still be installed from a previous run ctrctl run --privileged --rm "$IMAGE_TONISTIIGI_BINFMT" --uninstall qemu-s390x else skip "only required on Windows" fi } @test 'deploy s390x container does not work' { assert_success=false check_uname s390x s390x assert_failure assert_output --partial "exec /bin/uname: exec format error" } @test 'install s390x emulator' { ctrctl run --privileged --rm "$IMAGE_TONISTIIGI_BINFMT" --install s390x } @test 'deploy s390x container' { check_uname s390x s390x } ================================================ FILE: bats/tests/containers/published-ports.bats ================================================ # bats file_tags=opensuse load '../helpers/load' @test 'factory reset' { factory_reset } @test 'start container engine' { start_container_engine wait_for_container_engine } run_container_with_published_port() { ctrctl pull --quiet "$IMAGE_NGINX" ctrctl run -d -p "$@" --restart=no "$IMAGE_NGINX" } verify_container_published_port() { run try --max 9 --delay 10 curl --insecure --verbose --show-error "$@" assert_success assert_output --partial 'Welcome to nginx!' } @test 'container published port binding to localhost' { run_container_with_published_port "127.0.0.1:8080:80" verify_container_published_port "http://127.0.0.1:8080" } @test 'container published port binding to localhost should not be accessible via 0.0.0.0' { skip_unless_host_ip run curl --verbose --head "http://${HOST_IP}:8080" assert_output --partial "curl: (7) Failed to connect" } @test 'container published port binding to 0.0.0.0' { skip_unless_host_ip run_container_with_published_port "8081:80" verify_container_published_port "http://${HOST_IP}:8081" } ================================================ FILE: bats/tests/containers/published-udp-ports.bats ================================================ load '../helpers/load' local_setup() { skip_on_unix } @test 'factory reset' { factory_reset } build_alpine_socat_image() { cat <&3 "${assert}_not_exists" "$dir" done if is_false "${CACHE:-1}"; then echo "# assert that cache does not exist" >&3 assert_not_exists "$PATH_CACHE" else echo "# assert that cache does exists" >&3 assert_exists "$PATH_CACHE" fi } check_docker_symlinks() { skip_on_windows # Check if docker-X symlinks were deleted for dfile in docker-buildx docker-compose; do run readlink "$HOME/.docker/cli-plugins/$dfile" "${refute:?}_output" "$HOME/.rd/bin/$dfile" done } check_path() { skip_on_windows # Check if ./rd/bin was removed from the path # TODO add check for config.fish env_profiles=( "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.cshrc" "$HOME/.tcshrc" ) for candidate in .bash_profile .bash_login .profile; do if [ -e "$HOME/$candidate" ]; then env_profiles+=("$HOME/$candidate") # Only the first candidate that exists will be modified if [ "${assert}" = "refute" ]; then break fi fi done for profile in "${env_profiles[@]}"; do echo "$assert that $profile does not add ~/.rd/bin to the PATH" # cshrc: setenv PATH "/Users/jan/.rd/bin"\:"$PATH" # posix: export PATH="/Users/jan/.rd/bin:$PATH" run grep "PATH.\"$HOME/.rd/bin" "$profile" "${assert}_failure" done } check_rd_context() { skip_on_windows # Check if the rancher-desktop docker context has been removed if using_docker; then echo "$assert that the docker context rancher-desktop does not exist" run grep -r rancher-desktop "$HOME/.docker/contexts/meta" "${assert}_failure" fi } check_lima() { skip_on_windows # Check that the VM has been removed and no longer exists. run limactl ls "${assert}_output" --regexp "No instance found|no such file or directory" } check_WSL() { skip_on_unix # Check if rancher-desktop WSL distros are deleted on Windows run powershell.exe -c "wsl.exe --list" "${refute}_output" --partial "rancher-desktop-data" "${refute}_output" --partial "rancher-desktop" } check_updater_longhorn_gone() { assert_not_exists "$PATH_CACHE/updater-longhorn.json" } touch_updater_longhorn() { touch "$PATH_CACHE/updater-longhorn.json" } ================================================ FILE: bats/tests/containers/run-rancher.bats ================================================ # bats file_tags=opensuse load '../helpers/load' RD_FILE_RAMDISK_SIZE=12 # We need more disk to run the Rancher image. @test 'factory reset' { factory_reset } @test 'start container engine' { start_container_engine wait_for_container_engine } @test 'run rancher' { local rancher_image rancher_image="rancher/rancher:$(rancher_image_tag)" ctrctl pull --quiet "$rancher_image" ctrctl run --privileged -d --restart=no -p 8080:80 -p 8443:443 --name rancher "$rancher_image" } @test 'verify rancher' { local max_tries=9 if [[ -n ${CI:-} ]]; then max_tries=30 fi run try --max $max_tries --delay 10 curl --insecure --silent --show-error "https://localhost:8443/dashboard/auth/login" assert_success assert_output --partial "Rancher Dashboard" run ctrctl logs rancher assert_success assert_output --partial "Bootstrap Password:" } ================================================ FILE: bats/tests/containers/split-dns-vpn.bats ================================================ # bats file_tags=opensuse load '../helpers/load' REGISTRY_URL=$(echo "$RD_VPN_TEST_IMAGE" | cut -d'/' -f1) local_setup() { if ! using_vpn_test_image; then skip "This test requires a connection to the designated VPN." fi } @test 'factory reset' { factory_reset } @test 'start container engine' { start_container_engine wait_for_container_engine } @test 'Can access private registry over VPN from host' { run curl -I -k "https://$REGISTRY_URL/v2" assert_success # We avoid assert_line here due to the trailing carriage return (\r) issues. assert_output --partial "docker-distribution-api-version: registry/2.0" } @test 'Can pull image from private registry over VPN' { run ctrctl pull --quiet "$RD_VPN_TEST_IMAGE" assert_success } @test 'Can verify container access to the registry' { run ctrctl run --rm "$IMAGE_NGINX" curl -I -k "https://$REGISTRY_URL/v2" assert_success assert_output --partial "docker-distribution-api-version: registry/2.0" } @test 'Verify that a container can ping host.rancher-desktop.internal when the VPN is enabled' { run ctrctl run --rm "$IMAGE_BUSYBOX" timeout -s INT 10 ping -c 5 host.rancher-desktop.internal assert_success assert_output --partial "5 packets transmitted, 5 packets received, 0% packet loss" } ================================================ FILE: bats/tests/containers/switch-engines.bats ================================================ # Test case 20 load '../helpers/load' RD_CONTAINER_ENGINE=moby switch_container_engine() { local name=$1 RD_CONTAINER_ENGINE="${name}" # Make sure the backend is idle, to prevent wait_for_container_engine from # erroring because the wrong engine is up. wait_for_backend rdctl set --container-engine.name="${name}" wait_for_container_engine } pull_containers() { ctrctl pull --quiet "$IMAGE_NGINX" ctrctl pull --quiet "$IMAGE_BUSYBOX" ctrctl run -d -p 8085:80 --restart=no "$IMAGE_NGINX" ctrctl run -d --restart=always "$IMAGE_BUSYBOX" /bin/sh -c "sleep inf" run ctrctl ps --format '{{json .Image}}' assert_output --partial "$IMAGE_NGINX" assert_output --partial "$IMAGE_BUSYBOX" } @test 'factory reset' { factory_reset } @test 'start moby and pull nginx' { start_container_engine wait_for_container_engine pull_containers } @test "switch to containerd" { switch_container_engine containerd pull_containers } verify_post_switch_containers() { run ctrctl ps --format '{{json .Image}}' assert_output --partial "$IMAGE_BUSYBOX" refute_output --partial "$IMAGE_NGINX" } switch_back_verify_post_switch_containers() { local name=$1 switch_container_engine "${name}" try --max 12 --delay 5 verify_post_switch_containers } @test 'switch back to moby and verify containers' { switch_back_verify_post_switch_containers moby } @test 'switch back to containerd and verify containers' { switch_back_verify_post_switch_containers containerd } ================================================ FILE: bats/tests/containers/volumes.bats ================================================ load '../helpers/load' get_tempdir() { if ! is_windows || ! using_windows_exe; then echo "$BATS_TEST_TMPDIR" return fi # On Windows, create a temporary directory that is in the Windows temporary # directory so that it mounts correctly. Note that in CI we end up running # with PSModulePath set to pwsh (7.x) paths, and that breaks the code for # PowerShell 5.1. So we need to have alternative code in that case. # See also https://github.com/PowerShell/PowerShell/issues/14100 if command -v pwsh.exe &>/dev/null; then # shellcheck disable=SC2016 # Don't expand PowerShell expansion local command=' $([System.IO.Directory]::CreateTempSubdirectory()).FullName ' run pwsh.exe -Command "$command" assert_success else # PowerShell 5.1 is built against .net Framework 4.x and doesn't have # [System.IO.Directory]::CreateTempSubdirectory(); create a temporary # file and use its name instead. # shellcheck disable=SC2016 # Don't expand PowerShell expansion local command=' $name = New-TemporaryFile Remove-Item -Path $name # In case anti-virus etc. holds files open, wait for a second to let # things settle before we create a new directory with the same name. Start-Sleep -Seconds 1 New-Item -Type Directory -Path $name | Out-Null $name.FullName ' run powershell.exe -Command "$command" assert_success fi run wslpath -u "$output" assert_success echo "$output" | tr -d "\r" } local_setup() { run get_tempdir assert_success export WORK_PATH=$output run host_path "$WORK_PATH" assert_success export HOST_WORK_PATH=$output export EXPECT_FAILURE=false } local_teardown() { # Only do manual deletion on Windows; elsewhere we use BATS_TEST_TMPDIR so # BATS is expected to do the cleanup. if is_windows && [[ -n $HOST_WORK_PATH ]]; then powershell.exe -Command "Remove-Item -Recurse -LiteralPath '$HOST_WORK_PATH'" fi } known_failure_on_mount_type() { local mount_type=$1 local actual_type=$RD_MOUNT_TYPE if is_windows; then if using_windows_exe; then actual_type=win32 else actual_type=wsl fi fi if [ "$actual_type" = "$mount_type" ]; then comment "Test is known to fail on $RD_MOUNT_TYPE mounts" assert=refute refute=assert EXPECT_FAILURE=true fi } @test 'factory reset' { factory_reset } @test 'start container engine' { if is_linux; then # On linux, mount BATS_RUN_TMPDIR into the VM so that we can use # BATS_TEST_TMPDIR as a volume. local override_dir="${HOME}/.local/share/rancher-desktop/lima/_config" mkdir -p "$override_dir" { echo "mounts:" echo "- location: ${BATS_RUN_TMPDIR}" echo " writable: true" } >"$override_dir/override.yaml" fi start_container_engine wait_for_container_engine } @test 'read-only volume mount' { # Read a file that was created outside the container. file_name=foo file_path=$WORK_PATH/$file_name file_content=hello assert_not_exists "$file_path" create_file "$file_path" <<<$file_content # Use `--separate-stderr` to avoid image pull messages. run --separate-stderr \ ctrctl run --volume "$HOST_WORK_PATH:/mount:ro" \ "$IMAGE_BUSYBOX" cat /mount/$file_name assert_success assert_output $file_content } @test 'read-write volume mount' { file_name=foo file_path=$WORK_PATH/$file_name file_content=hello # Create a file from the container. assert_not_exists "$file_path" ctrctl run --volume "$HOST_WORK_PATH:/mount:rw" \ "$IMAGE_BUSYBOX" sh -c "echo $file_content > /mount/$file_name" # Check that the file was written to. assert_file_contains "$file_path" $file_content } @test 'read-write single file using --mount' { file_name=foo file_content=hello create_file "$WORK_PATH/$file_name" <<<$file_content run --separate-stderr \ ctrctl run --mount "source=$HOST_WORK_PATH/$file_name,target=/mount,type=bind" \ "$IMAGE_BUSYBOX" cat /mount assert_success assert_output $file_content } @test 'read-write volume mount as user' { known_failure_on_mount_type 9p file_name=foo file_contents=hello host_file_path=$HOST_WORK_PATH/$file_name # Create a file from within the container. run ctrctl run --volume "$HOST_WORK_PATH:/mount:rw" \ --user 1000:1000 "$IMAGE_BUSYBOX" sh -c "echo $file_contents > /mount/$file_name" "${assert}_success" run cat "$WORK_PATH/$file_name" "${assert}_success" if is_true "$EXPECT_FAILURE"; then skip "Test expected to fail" fi assert_output $file_contents # Try to append to the file. ctrctl run --volume "$HOST_WORK_PATH:/mount:rw" \ --user 1000:1000 "$IMAGE_BUSYBOX" sh -c "echo $file_contents | tee -a /mount/$file_name" # Check that the file was modified. run cat "$WORK_PATH/$file_name" assert_success assert_output $file_contents$'\n'$file_contents if is_windows && using_windows_exe; then # On Windows, the directory may be owned by a group that the user is in; # additionally, there isn't an easy API to get effective access (!?). if command -v pwsh.exe &>/dev/null; then # shellcheck disable=SC2016 # Don't expand PowerShell expansion local command=' $typeName = "System.Security.Principal.SecurityIdentifier, System.Security.Principal.Windows" $type = [System.Type]::GetType($typeName) $owner = $(Get-Acl '"'$host_file_path'"').GetOwner($type) $owner.Value ' run pwsh.exe -Command "$command" assert_success else # shellcheck disable=SC2016 # Don't expand PowerShell expansion local command=' $type = [System.Type]::GetType("System.Security.Principal.SecurityIdentifier") $owner = $(Get-Acl '"'$host_file_path'"').GetOwner($type) $owner.Value ' run powershell.exe -Command "$command" assert_success fi local undo undo=$(shopt -p extglob || true) shopt -s extglob local owner=${output%%*([[:space:]])} eval "$undo" # shellcheck disable=SC2016 # Don't expand PowerShell expansion command=' $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() $groups = $identity.Groups $groups.Add($identity.User) $groups | ForEach-Object { $_.Value } ' run powershell.exe -Command "$command" assert_success run cat <<<"${output//$'\r'/}" # Remove carriage returns assert_success assert_line "$owner" else # Check that the file is owned by the current user. stat_arg=-f # Assume BSD stat if { stat --version || true; } | grep 'GNU coreutils'; then stat_arg=-c fi run stat "$stat_arg" '%u:%g' "$WORK_PATH/foo" assert_success assert_output "$(id -u):$(id -g)" fi } @test 'host directory does not exist' { if using_docker; then known_failure_on_mount_type reverse-sshfs known_failure_on_mount_type 9p fi file_name=foo dir_name=baz file_contents=hello # Create a file from the container. assert_not_exists "$WORK_PATH/$dir_name" run ctrctl run --volume "$HOST_WORK_PATH/$dir_name:/mount:rw" \ "$IMAGE_BUSYBOX" sh -c "echo $file_contents > /mount/$file_name" "${assert}_success" # Check that the file was written to. if is_true "$EXPECT_FAILURE"; then assert_file_not_exists "$WORK_PATH/$dir_name" else assert_file_exists "$WORK_PATH/$dir_name/$file_name" assert_file_contains "$WORK_PATH/$dir_name/$file_name" $file_contents fi } @test 'directory contains space' { dir_name="hello world" file_name=foo file_contents=hello assert_not_exists "$WORK_PATH/$dir_name" mkdir "$WORK_PATH/$dir_name" ctrctl run --volume "$HOST_WORK_PATH/$dir_name:/mount:rw" \ "$IMAGE_BUSYBOX" sh -c "echo $file_contents > /mount/$file_name" assert_file_exists "$WORK_PATH/$dir_name/$file_name" assert_file_contains "$WORK_PATH/$dir_name/$file_name" $file_contents } @test 'directory contains non-ascii' { dir_name=snow☃︎man file_name=foo file_contents=hello assert_not_exists "$WORK_PATH/$dir_name" mkdir "$WORK_PATH/$dir_name" ctrctl run --volume "$HOST_WORK_PATH/$dir_name:/mount:rw" \ "$IMAGE_BUSYBOX" sh -c "echo $file_contents > /mount/$file_name" assert_file_exists "$WORK_PATH/$dir_name/$file_name" assert_file_contains "$WORK_PATH/$dir_name/$file_name" "$file_contents" } @test 'directory should be owned by current user' { known_failure_on_mount_type virtiofs known_failure_on_mount_type 9p known_failure_on_mount_type reverse-sshfs known_failure_on_mount_type win32 user_id=3678:2974 run --separate-stderr \ ctrctl run --volume "$HOST_WORK_PATH:/mount:ro" \ --user $user_id "$IMAGE_BUSYBOX" stat -c '%u:%g' /mount assert_success "${assert}_output" $user_id } @test 'change ownership of mounted file' { known_failure_on_mount_type reverse-sshfs known_failure_on_mount_type 9p file_name=foo file_contents=hello run ctrctl run --volume "$HOST_WORK_PATH:/mount:rw" \ --user 0 "$IMAGE_BUSYBOX" \ sh -c "echo $file_contents > /mount/$file_name; chown 1234:5678 /mount/$file_name" "${assert}_success" assert_file_exists "$WORK_PATH/$file_name" assert_file_contains "$WORK_PATH/$file_name" "$file_contents" } @test 'change file permissions' { file_name=foo assert_not_exists "$WORK_PATH/$file_name" local command=" touch /mount/$file_name chmod 0755 /mount/$file_name stat -c %A /mount/$file_name " run --separate-stderr \ ctrctl run --volume "$HOST_WORK_PATH:/mount:rw" \ "$IMAGE_BUSYBOX" sh -c "$command" assert_success "${assert}_output" -rwxr-xr-x # spellcheck-ignore-line } ================================================ FILE: bats/tests/containers/wasm.bats ================================================ # shellcheck disable=SC2030,SC2031 # See https://github.com/koalaman/shellcheck/issues/2431 # https://www.shellcheck.net/wiki/SC2030 -- Modification of output is local (to subshell caused by @bats test) # https://www.shellcheck.net/wiki/SC2031 -- output was modified in a subshell. That change might be lost load '../helpers/load' # Bundled shims are this version or newer. BUNDLED_VERSION=0.11.1 # Manually managed versions intentionally use an older version # so we can verify that they still override the bundled version. MANUAL_VERSION=0.10.0 local_setup() { if using_containerd; then skip "this test only works on moby right now" fi skip "spin shim is broken with docker 28+; see #9476" } local_teardown_file() { rm -rf "$PATH_CONTAINERD_SHIMS" } @test 'factory reset' { factory_reset rm -rf "$PATH_CONTAINERD_SHIMS" } @test 'start engine without wasm support' { start_container_engine --experimental.container-engine.web-assembly.enabled=false wait_for_container_engine } shim_version() { local shim=$1 local version=$2 run rdctl shell "containerd-shim-${shim}-${version}" -v assert_success semver "$output" } @test 'verify spin shim is not installed on PATH' { run shim_version spin v2 assert_failure assert_output --regexp 'containerd-shim-spin-v2.*(not found|No such file)' } hello() { local shim=$1 local version=$2 local lang=$3 local port=$4 local internal_port=$5 # The '/' at the very end of the command is required by the container entrypoint. ctrctl run \ --detach \ --name "${shim}-demo-${port}" \ --runtime "io.containerd.${shim}.${version}" \ --platform wasi/wasm \ --publish "${port}:${internal_port}" \ "ghcr.io/deislabs/containerd-wasm-shims/examples/${shim}-${lang}-hello:v${MANUAL_VERSION}" / } @test 'verify shim is not configured in container engine' { run hello spin v2 rust 8080 80 assert_nothing # We assert after removing the container. ctrctl rm --force spin-demo-8080 || true # Force delete the container if it got created. assert_failure assert_output --regexp 'operating system is not supported|binary not installed' } @test 'enable wasm support' { pid=$(get_service_pid "$CONTAINER_ENGINE_SERVICE") rdctl set --experimental.container-engine.web-assembly.enabled try --max 15 --delay 5 refute_service_pid "$CONTAINER_ENGINE_SERVICE" "$pid" wait_for_container_engine } @test "check spin shim version >= ${BUNDLED_VERSION}" { run shim_version spin v2 assert_success semver_gte "$output" "$BUNDLED_VERSION" } @test 'deploy sample spin app' { hello spin v2 rust 8080 80 } check_container_logs() { run ctrctl logs spin-demo-8080 assert_success assert_output --partial "Available Routes" } @test 'check wasm container logs' { try --max 5 --delay 2 check_container_logs } @test 'verify wasm container is running' { run curl --silent --fail http://localhost:8080/hello assert_success assert_output --partial "Hello world from Spin!" run curl --silent --fail http://localhost:8080/go-hello assert_success assert_output --partial "Hello Spin Shim!" } download_shim() { local shim=$1 local version=$2 local base_url="https://github.com/deislabs/containerd-wasm-shims/releases/download/v${MANUAL_VERSION}" local filename="containerd-wasm-shims-${version}-${shim}-linux-${ARCH}.tar.gz" local host_archive # Since we end up using curl.exe on Windows, pass the host path to curl. host_archive=$(host_path "${PATH_CONTAINERD_SHIMS}/${filename}") mkdir -p "$PATH_CONTAINERD_SHIMS" curl --location --output "$host_archive" "${base_url}/${filename}" tar xfz "${PATH_CONTAINERD_SHIMS}/${filename}" --directory "$PATH_CONTAINERD_SHIMS" rm "${PATH_CONTAINERD_SHIMS}/${filename}" } @test 'install user-managed shims' { download_shim spin v2 download_shim wws v1 rdctl shutdown launch_the_application wait_for_container_engine } verify_shim() { local shim=$1 local version=$2 local lang=$3 local port=$4 local external_port=$5 run shim_version "${shim}" "${version}" assert_success semver_eq "$output" "$MANUAL_VERSION" hello "$shim" "$version" "$lang" "$port" "$external_port" try --max 10 --delay 3 curl --silent --fail "http://localhost:${port}/hello" } @test 'verify spin shim' { verify_shim spin v2 rust 8181 80 assert_output --partial "Hello world from Spin!" } @test 'verify wws shim' { verify_shim wws v1 js 8282 3000 assert_output --partial "Hello from Wasm Workers Server" } ================================================ FILE: bats/tests/extensions/allow-list.bats ================================================ load '../helpers/load' local_setup() { CONTAINERD_NAMESPACE=rancher-desktop-extensions TESTDATA_DIR_HOST=$(host_path "${PATH_BATS_ROOT}/tests/extensions/testdata/") } write_allow_list() { # list local list=${1:-} local allowed=true if [ -z "$list" ]; then allowed=false fi # Note that the list preference is not writable using `rdctl set`, and we # need to do a direct API call instead. rdctl api /v1/settings --input - <<<'{ "version": 8, "application": { "extensions": { "allowed": { "enabled": '"${allowed}"', "list": '"${list:-[]}"' } } } }' } check_extension_installed() { # refute, name run rdctl extension ls assert_success "${1:-assert}_output" --partial "${2:-rd/extension/basic}" } @test 'factory reset' { factory_reset } @test 'start container engine' { start_container_engine wait_for_container_engine } @test 'build extension testing image' { ctrctl build \ --tag "rd/extension/basic" \ --build-arg "variant=basic" \ "$TESTDATA_DIR_HOST" run ctrctl image list --format '{{ .Repository }}' assert_success assert_line "rd/extension/basic" } @test 'when no extension allow list is set up, all extensions can install' { wait_for_extension_manager write_allow_list '' rdctl extension install rd/extension/basic check_extension_installed rdctl extension uninstall rd/extension/basic } @test 'empty allow list disables extension installs' { write_allow_list '[]' run rdctl extension install rd/extension/basic assert_failure check_extension_installed refute } @test 'when an extension is explicitly allowed, it can be installed' { write_allow_list '["irrelevant/image","rd/extension/basic:latest"]' rdctl extension install rd/extension/basic:latest check_extension_installed rdctl extension uninstall rd/extension/basic check_extension_installed refute } @test 'when an extension is not in the allowed list, it cannot be installed' { write_allow_list '["rd/extension/other","registry.test/image"]' run rdctl extension install rd/extension/basic assert_failure check_extension_installed refute } @test 'when no tags given, any tag is allowed' { write_allow_list '["rd/extension/basic"]' ctrctl tag rd/extension/basic rd/extension/basic:0.0.3 rdctl extension install rd/extension/basic:0.0.3 check_extension_installed rdctl extension uninstall rd/extension/basic check_extension_installed refute } @test 'when tags are given, only the specified tag is allowed' { sleep 20 write_allow_list '["rd/extension/basic:0.0.2"]' ctrctl tag rd/extension/basic rd/extension/basic:0.0.3 run rdctl extension install rd/extension/basic:0.0.3 assert_failure check_extension_installed refute } @test 'extensions can be allowed by organization' { write_allow_list '["rd/extension/"]' rdctl extension install rd/extension/basic check_extension_installed rdctl extension uninstall rd/extension/basic check_extension_installed refute } @test 'extensions can be allowed by repository host' { write_allow_list '["registry.test/"]' ctrctl tag rd/extension/basic registry.test/basic:0.0.3 rdctl extension install registry.test/basic:0.0.3 check_extension_installed '' registry.test/basic rdctl extension uninstall registry.test/basic check_extension_installed refute registry.test/basic } ================================================ FILE: bats/tests/extensions/containers.bats ================================================ load '../helpers/load' local_setup() { CONTAINERD_NAMESPACE=rancher-desktop-extensions TESTDATA_DIR_HOST=$(host_path "${PATH_BATS_ROOT}/tests/extensions/testdata/") } local_teardown_file() { if using_docker; then docker context use default docker context rm bats-invalid-context fi } id() { # variant echo "rd/extension/$1" } encoded_id() { # variant id "$1" | tr -d '\r\n' | base64 | tr '+/' '-_' | tr -d '=' } @test 'factory reset' { factory_reset } @test 'start container engine' { start_container_engine wait_for_container_engine } @test 'default to custom docker context' { if ! using_docker; then skip 'docker context only applies when using docker backend' fi # Remove the context if it previously existed. run docker context rm --force bats-invalid-context assert_nothing docker context create bats-invalid-context --docker 'host=tcp://invalid.test:99999' docker context use bats-invalid-context } @test 'no extensions installed' { wait_for_extension_manager run rdctl api /v1/extensions assert_success assert_output $'\x7b'$'\x7d' # empty JSON dict, {} assert_dir_not_exist "$PATH_EXTENSIONS" } @test 'build extension testing images' { local extension for extension in vm-image vm-compose; do ctrctl build \ --tag rd/extension/$extension \ --build-arg variant=$extension "$TESTDATA_DIR_HOST" done } @test 'image - install' { rdctl api --method=POST "/v1/extensions/install?id=$(id vm-image)" run rdctl api /v1/extensions assert_success run jq_output ".[\"$(id vm-image)\"].version" assert_output latest } @test 'image - check for running container' { run ctrctl container ls assert_success assert_line --regexp "$(id vm-image).*[[:space:]]Up[[:space:]]" } @test 'image - uninstall' { rdctl api --method=POST "/v1/extensions/uninstall?id=$(id vm-image)" run ctrctl container ls --all assert_success refute_line --partial "$(id vm-image)" } @test 'compose - install' { rdctl api --method=POST "/v1/extensions/install?id=$(id vm-compose)" run rdctl api /v1/extensions assert_success run jq_output ".[\"$(id vm-compose)\"].version" assert_output latest } @test 'compose - check for running container' { run ctrctl container ls assert_success assert_line --regexp "$(id vm-compose).*[[:space:]]Up[[:space:]]" } @test 'compose - check for dangling symlinks' { if ! using_containerd; then skip 'This test only applies to containerd' fi assert_exists "$PATH_EXTENSIONS/$(encoded_id vm-compose)/compose/link" assert_not_exists "$PATH_EXTENSIONS/$(encoded_id vm-compose)/compose/dangling-link" } @test 'compose - uninstall' { rdctl api --method=POST "/v1/extensions/uninstall?id=$(id vm-compose)" run ctrctl container ls --all assert_success refute_line --partial "$(id vm-compose)" } @test 'compose - with a long name' { local name name="$(id vm-compose)-with-an-unusually-long-name-yes-it-is-very-long" ctrctl tag "$(id vm-compose)" "$name" rdctl extension install "$name" run ctrctl container ls --all assert_success assert_line --partial "$(id vm-compose)" rdctl extension uninstall "$name" } ================================================ FILE: bats/tests/extensions/install.bats ================================================ load '../helpers/load' local_setup() { CONTAINERD_NAMESPACE=rancher-desktop-extensions TESTDATA_DIR="${PATH_BATS_ROOT}/tests/extensions/testdata/" TESTDATA_DIR_HOST=$(host_path "$TESTDATA_DIR") } assert_file_contents_equal() { # $have $want local have="$1" want="$2" assert_file_exist "$have" assert_file_exist "$want" local have_hash want_hash # md5sum is not available on macOS unless you install GNU coreutils have_hash="$(openssl md5 -r "$have" | cut -d ' ' -f 1)" want_hash="$(openssl md5 -r "$want" | cut -d ' ' -f 1)" if [ "$have_hash" != "$want_hash" ]; then printf "expected : %s (%s)\nactual : %s (%s)" \ "$want" "$want_hash" "$have" "$have_hash" | batslib_decorate "files are different" | fail fi } id() { # variant echo "rd/extension/$1" } encoded_id() { # variant id "$1" | tr -d '\r\n' | base64 | tr '+/' '-_' | tr -d '=' } @test 'factory reset' { factory_reset } @test 'start container engine' { start_container_engine wait_for_container_engine wait_for_extension_manager } @test 'no extensions installed' { run rdctl extension ls assert_success assert_output "No extensions are installed." assert_dir_not_exist "$PATH_EXTENSIONS" } @test 'build various extension testing images' { local extension local variants=( basic host-binaries missing-icon missing-icon-file ui ) for extension in "${variants[@]}"; do ctrctl build \ --tag "rd/extension/$extension" \ --build-arg "variant=$extension" \ "$TESTDATA_DIR_HOST" done run ctrctl image list --format '{{ .Repository }}' assert_success for extension in "${variants[@]}"; do assert_line "rd/extension/$extension" done } @test 'extension API - require auth' { local port run cat "${PATH_APP_HOME}/rd-engine.json" assert_success port="$(jq_output .port)" assert [ -n "$port" ] run curl --fail "http://127.0.0.1:${port}/v1/settings" assert_failure assert_output --partial "The requested URL returned error: 401" } @test 'basic extension - install' { assert_dir_not_exist "$PATH_EXTENSIONS/$(encoded_id basic)" rdctl extension install "$(id basic)" } @test 'basic extension - check extension is installed' { run rdctl extension ls assert_success assert_line --partial "rd/extension/basic" } @test 'basic extension - check extension contents' { assert_dir_exist "$PATH_EXTENSIONS/$(encoded_id basic)" assert_file_contents_equal "$PATH_EXTENSIONS/$(encoded_id basic)/icon.svg" "$TESTDATA_DIR/extension-icon.svg" } @test 'basic extension - upgrades' { local tag ctrctl image tag "$(id basic)" "$(id basic):0.0.1" ctrctl image tag "$(id basic)" "$(id basic):v0.0.2" run rdctl extension ls assert_success assert_line --partial "$(id basic):latest" rdctl extension install "$(id basic)" run rdctl extension ls assert_success # The highest semver tag should be installed, replacing the existing one. assert_line --partial "$(id basic):v0.0.2" } @test 'basic extension - uninstalling not installed version' { rdctl extension uninstall "$(id basic):0.0.1" run rdctl extension ls assert_success # Trying to uninstall a version that isn't installed should be a no-op assert_line --partial "$(id basic):v0.0.2" } @test 'basic extension - uninstall' { ctrctl image tag "$(id basic)" "$(id basic):0.0.3" # Uninstall should remove whatever version is installed, not the newest. rdctl extension uninstall "$(id basic)" run rdctl extension ls assert_success assert_output "No extensions are installed." assert_dir_not_exist "$PATH_EXTENSIONS/$(encoded_id basic)" } @test 'missing-icon - attempt to install' { assert_dir_not_exist "$PATH_EXTENSIONS/$(encoded_id missing-icon)" run rdctl extension install "$(id missing-icon)" assert_failure assert_output --partial "has invalid extension metadata" assert_dir_not_exist "$PATH_EXTENSIONS/$(encoded_id missing-icon)" } @test 'missing-icon-file - attempt to install' { assert_dir_not_exist "$PATH_EXTENSIONS/$(encoded_id missing-icon-file)" run rdctl extension install "$(id missing-icon-file)" assert_failure assert_output --partial "Could not copy icon file does-not-exist.svg" assert_dir_not_exist "$PATH_EXTENSIONS/$(encoded_id missing-icon-file)" } @test 'host-binaries - install' { assert_dir_not_exist "$PATH_EXTENSIONS/$(encoded_id host-binaries)" run rdctl extension install "$(id host-binaries)" assert_success } @test 'host-binaries - check extension is installed' { run rdctl extension ls assert_success assert_output --partial "rd/extension/host-binaries:latest" } @test 'host-binaries - check files' { assert_dir_exist "$PATH_EXTENSIONS/$(encoded_id host-binaries)" if is_windows; then assert_file_exist "$PATH_EXTENSIONS/$(encoded_id host-binaries)/bin/dummy.exe" assert_file_not_exist "$PATH_EXTENSIONS/$(encoded_id host-binaries)/bin/dummy.sh" else assert_file_exist "$PATH_EXTENSIONS/$(encoded_id host-binaries)/bin/dummy.sh" assert_file_not_exist "$PATH_EXTENSIONS/$(encoded_id host-binaries)/bin/dummy.exe" fi } @test 'host-binaries - upgrade' { # We test upgrades with host-binaries as there was a bug about reinstalling # an extension with host binaries. ctrctl image tag "$(id host-binaries)" "$(id host-binaries):0.0.1" ctrctl image tag "$(id host-binaries)" "$(id host-binaries):v0.0.2" run rdctl extension ls assert_success assert_line --partial "$(id host-binaries):latest" rdctl extension install "$(id host-binaries)" run rdctl extension ls assert_success # The highest semver tag should be installed, replacing the existing one. assert_line --partial "$(id host-binaries):v0.0.2" } @test 'host-binaries - uninstalling not installed version' { rdctl extension uninstall "$(id host-binaries):0.0.1" run rdctl extension ls assert_success # Trying to uninstall a version that isn't installed should be a no-op assert_line --partial "$(id host-binaries):v0.0.2" } @test 'host-binaries - uninstall' { ctrctl image tag "$(id host-binaries)" "$(id host-binaries):0.0.3" # Uninstall should remove whatever version is installed, not the newest. rdctl extension uninstall "$(id host-binaries)" run rdctl extension ls assert_success assert_output "No extensions are installed." assert_dir_not_exist "$PATH_EXTENSIONS/$(encoded_id host-binaries)" } @test 'ui - install' { assert_dir_not_exist "$PATH_EXTENSIONS/$(encoded_id ui)" run rdctl extension install "$(id ui)" assert_success } @test 'ui - check files' { assert_file_exist "$PATH_EXTENSIONS/$(encoded_id ui)/ui/dashboard-tab/ui/index.html" } @test 'ui - uninstall' { rdctl extension uninstall "$(id ui)" run rdctl extension ls assert_success assert_output "No extensions are installed." assert_dir_not_exist "$PATH_EXTENSIONS/$(encoded_id ui)" } ================================================ FILE: bats/tests/extensions/testdata/Dockerfile ================================================ FROM registry.suse.com/bci/golang:latest AS builder WORKDIR /usr/src/app COPY bin/dummy.go . ENV GOOS=windows RUN go build -o /dummy.exe -ldflags '-s -w' dummy.go FROM registry.suse.com/bci/golang:latest AS server-builder WORKDIR /usr/src/app COPY bin/server.go . ENV GOOS=linux RUN go build -o /server -ldflags '-s -w' server.go FROM registry.suse.com/bci/bci-minimal:16.0 ARG variant=basic ADD ${variant}.json /metadata.json ADD extension-icon.svg /extension-icon.svg ADD ui /ui/ ADD bin /bin/ COPY --from=builder /dummy.exe /bin/ COPY --from=server-builder /server /bin/ ADD compose.yaml /compose/ RUN ln -s does/not/exist /compose/dangling-link RUN ln -s compose.yaml /compose/link ENTRYPOINT ["/bin/server"] ================================================ FILE: bats/tests/extensions/testdata/Makefile ================================================ all: \ image-basic image-missing-icon image-ui \ image-vm-image image-vm-compose image-host-binaries image-host-apis TOOL ?= docker NAMESPACE := $(if $(filter %nerdctl,${TOOL}),--namespace=rancher-desktop-extensions) NAMESPACE = $(if $(filter %nerdctl,${TOOL}),--namespace=rancher-desktop-extensions) image-%: ${TOOL} ${NAMESPACE} build -t rd/extension/$(@:image-%=%) --build-arg variant=$(@:image-%=%) . ================================================ FILE: bats/tests/extensions/testdata/README.md ================================================ This directory contains sample docker extensions. ### basic A basic extension, containing the bare minimum (just an icon). ### missing-icon As above, but even the icon is missing. ### missing-icon-file Like the basic extension, but the icon file specified does not exist. ### ui Presents basic UI. We do not yet test interaction with UI. ### vm-image Contains a docker image to run. ### vm-compose Contains a docker compose file to run. (Not supported.) ### host-binaries Contains binaries to be copied to the host. ================================================ FILE: bats/tests/extensions/testdata/basic.json ================================================ { "icon": "extension-icon.svg" } ================================================ FILE: bats/tests/extensions/testdata/bin/dummy.go ================================================ package main import ( "context" "log" "os" "os/exec" ) func main() { ctx := context.Background() cmd := exec.CommandContext(ctx, os.Args[1], os.Args[2:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err := cmd.Run() if exitError, ok := err.(*exec.ExitError); ok { if exitError.ExitCode() > -1 { os.Exit(exitError.ExitCode()) } } if err != nil { log.Fatal(err) } } ================================================ FILE: bats/tests/extensions/testdata/bin/dummy.sh ================================================ #!/bin/sh exec "$@" ================================================ FILE: bats/tests/extensions/testdata/bin/server.go ================================================ // command server listens on the Unix socket `/run/guest-services/hello.sock` // (see `everything.json`) to exercise the ability for the front end to talk to // the back end. package main import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net" "net/http" "os" "os/signal" "strconv" "syscall" ) const ( addr = "/run/guest-services/hello.sock" ) // Listen on a port and return the listener func listen() (net.Listener, error) { err := os.Remove(addr) if err != nil && !errors.Is(err, os.ErrNotExist) { slog.Error("failed to remove old socket", "socket", addr, "error", err) } listener, err := net.Listen("unix", addr) if err == nil { return listener, nil } listener, err = net.Listen("tcp", "") if err != nil { return nil, fmt.Errorf("failed to listen on fallback TCP: %w", err) } return listener, nil } // Handle HTTP POST requests func handlePost(w http.ResponseWriter, req *http.Request) { data := map[string]any{"headers": req.Header} body, err := io.ReadAll(req.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) _, _ = io.WriteString(w, fmt.Sprintf("failed to read body: %s", err)) return } data["body"] = string(body) encoder := json.NewEncoder(w) encoder.SetIndent("", " ") if err := encoder.Encode(data); err != nil { // This ends up after partially written JSON, but that's the best we can do // and should still show up in the result. _, _ = io.WriteString(w, fmt.Sprintf("failed to encode response: %w", err)) } } // Handle POST returning given status func handleWithStatus(w http.ResponseWriter, req *http.Request) { statusText := req.PathValue("status") statusCode, err := strconv.Atoi(statusText) if err != nil { w.WriteHeader(http.StatusBadRequest) _, _ = fmt.Fprintf(w, "failed to parse status %s", statusText) return } w.WriteHeader(statusCode) _, _ = fmt.Fprintf(w, "returning status code %d", statusCode) } func main() { listener, err := listen() if err != nil { slog.Error("failed to listen", "error", err) os.Exit(1) } http.DefaultServeMux.Handle("GET /get/", http.StripPrefix("/get/", http.FileServer(http.Dir("/")))) http.DefaultServeMux.HandleFunc("POST /post", handlePost) http.DefaultServeMux.HandleFunc("/status/{status}", handleWithStatus) server := &http.Server{} ch := make(chan os.Signal) errCh := make(chan error) signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) go func() { <-ch errCh <- server.Shutdown(context.Background()) }() slog.Info("Serving HTTP", "address", listener.Addr().String()) err = server.Serve(listener) if err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server closed", "error", err) os.Exit(1) } if err = <-errCh; err != nil { slog.Error("failed to shutdown server", "error", err) os.Exit(1) } } ================================================ FILE: bats/tests/extensions/testdata/compose.yaml ================================================ name: sample-compose services: backend-service: image: "${DESKTOP_PLUGIN_IMAGE}" ================================================ FILE: bats/tests/extensions/testdata/everything.json ================================================ { "icon": "extension-icon.svg", "host": { "binaries": [ { "darwin": [ { "path": "/bin/dummy.sh" } ], "windows": [ { "path": "/bin/dummy.exe" } ], "linux": [ { "path": "/bin/dummy.sh" } ] } ] }, "ui": { "dashboard-tab": { "title": "Sample Extension With Everything", "root": "/ui", "src": "index.html", "backend": { "socket": "hello.sock" } } }, "vm": { "composefile": "/compose/compose.yaml", "exposes": { "socket": "hello.sock" } } } ================================================ FILE: bats/tests/extensions/testdata/host-apis.json ================================================ { "info": "This is used to do manual testing of various host APIs", "icon": "extension-icon.svg", "ui": { "dashboard-tab": { "title": "RDX Host-APIs Test", "root": "/ui", "src": "host-apis.html" } } } ================================================ FILE: bats/tests/extensions/testdata/host-binaries.json ================================================ { "icon": "extension-icon.svg", "host": { "binaries": [ { "darwin": [ { "path": "/bin/dummy.sh" } ], "windows": [ { "path": "/bin/dummy.exe" } ], "linux": [ { "path": "/bin/dummy.sh" } ] } ] } } ================================================ FILE: bats/tests/extensions/testdata/missing-icon-file.json ================================================ { "icon": "does-not-exist.svg", "info": "This extension uses an icon that does not exist." } ================================================ FILE: bats/tests/extensions/testdata/missing-icon.json ================================================ { "info": "This extension definition is missing an icon." } ================================================ FILE: bats/tests/extensions/testdata/ui/host-apis.html ================================================

Rancher Desktop Extensions Host API Testing

Open external

Clicking the button should open the Rancher Desktop home page in a browser.


Select file

Clicking on the button should show a file open dialog.

Dialog Output
cancelled
file paths

Toast

Clicking on each of the buttons should pop up a notification where the notification title matches the type clicked on.

================================================ FILE: bats/tests/extensions/testdata/ui/index.html ================================================ Test Extension

Test Extension

This is a test extension.

================================================ FILE: bats/tests/extensions/testdata/ui.json ================================================ { "icon": "extension-icon.svg", "ui": { "dashboard-tab": { "title": "Sample Extension", "root": "/ui", "src": "index.html" } } } ================================================ FILE: bats/tests/extensions/testdata/vm-compose.json ================================================ { "icon": "extension-icon.svg", "info": "This image uses vm.composefile", "vm": { "composefile": "/compose/compose.yaml" } } ================================================ FILE: bats/tests/extensions/testdata/vm-image.json ================================================ { "icon": "extension-icon.svg", "info": "This extension contains a reference to an image.", "vm": { "image": "rd/extension/vm-image" } } ================================================ FILE: bats/tests/helpers/commands.bash ================================================ EXE="" PLATFORM=$OS if is_windows; then PLATFORM=linux if using_windows_exe; then EXE=".exe" PLATFORM=win32 fi fi if using_containerd; then CONTAINER_ENGINE_SERVICE=containerd else CONTAINER_ENGINE_SERVICE=docker fi if is_macos; then CRED_HELPER="$PATH_RESOURCES/$PLATFORM/bin/docker-credential-osxkeychain" elif is_linux; then if command -v pass; then CRED_HELPER="$PATH_RESOURCES/$PLATFORM/bin/docker-credential-pass" else CRED_HELPER="$PATH_RESOURCES/$PLATFORM/bin/docker-credential-secretservice" fi elif is_windows; then # Our docker-cli for WSL defaults to "wincred.exe" as well CRED_HELPER="$PATH_RESOURCES/win32/bin/docker-credential-wincred.exe" fi if is_windows; then RD_DOCKER_CONTEXT=default else RD_DOCKER_CONTEXT=rancher-desktop fi CONTAINERD_NAMESPACE=default WSL_DISTRO=rancher-desktop no_cr() { tr -d '\r' } ctrctl() { if using_docker; then docker "$@" else nerdctl "$@" fi } curl() { command "curl$EXE" "$@" } docker() { docker_exe --context $RD_DOCKER_CONTEXT "$@" } docker_exe() { # Add path to bundled credential helpers to the front of the PATH; also # ensure that on Windows, it gets exported. PATH="$PATH_RESOURCES/$PLATFORM/bin:$PATH" WSLENV="PATH/l:${WSLENV:-}" \ "$PATH_RESOURCES/$PLATFORM/bin/docker$EXE" "$@" | no_cr } helm() { # Add path to bundled credential helpers to the front of the PATH; also # ensure that on Windows, it gets exported. PATH="$PATH_RESOURCES/$PLATFORM/bin:$PATH" WSLENV="PATH/l:${WSLENV:-}" \ "$PATH_RESOURCES/$PLATFORM/bin/helm$EXE" --kube-context rancher-desktop "$@" | no_cr } kubectl() { kubectl_exe --context rancher-desktop "$@" } kubectl_exe() { "$PATH_RESOURCES/$PLATFORM/bin/kubectl$EXE" "$@" | no_cr } limactl() { # LIMA_HOME is set by paths.bash but not exported LIMA_HOME="$LIMA_HOME" "$PATH_RESOURCES/$PLATFORM/lima/bin/limactl" "$@" } nerdctl() { # Add path to bundled credential helpers to the front of the PATH; also # ensure that on Windows, it gets exported. PATH="$PATH_RESOURCES/$PLATFORM/bin:$PATH" WSLENV="PATH/l:${WSLENV:-}" \ "$PATH_RESOURCES/$PLATFORM/bin/nerdctl$EXE" --namespace "$CONTAINERD_NAMESPACE" "$@" | no_cr } # Run `rdctl`; if $RD_TIMEOUT is set, the value is used as the first argument to # the `timeout` command. rdctl() { if is_windows; then timeout "${RD_TIMEOUT:-0}" "$PATH_RESOURCES/win32/bin/rdctl.exe" "$@" | no_cr else timeout "${RD_TIMEOUT:-0}" "$PATH_RESOURCES/$PLATFORM/bin/rdctl$EXE" "$@" fi } rdshell() { rdctl shell "$@" } rdsudo() { rdshell sudo "$@" } spin() { # spin may call itself recursively, so make sure it calls the correct binary PATH="$PATH_RESOURCES/$PLATFORM/bin:$PATH" "$PATH_RESOURCES/$PLATFORM/bin/spin$EXE" "$@" | no_cr } wsl() { wsl.exe -d "$WSL_DISTRO" "$@" } ================================================ FILE: bats/tests/helpers/defaults.bash ================================================ ######################################################################## : "${RD_CONTAINER_ENGINE:=containerd}" validate_enum RD_CONTAINER_ENGINE containerd moby using_containerd() { test "$RD_CONTAINER_ENGINE" = "containerd" } using_docker() { ! using_containerd } ######################################################################## : "${RD_RANCHER_IMAGE_TAG:=}" rancher_image_tag() { echo "${RANCHER_IMAGE_TAG:-v2.7.0}" } ######################################################################## # Defaults to true, except in the helper unit tests, which default to false : "${RD_INFO:=}" ######################################################################## : "${RD_CAPTURE_LOGS:=false}" capturing_logs() { is_true "$RD_CAPTURE_LOGS" } ######################################################################## : "${RD_NO_MODAL_DIALOGS:=true}" suppressing_modal_dialogs() { is_true "$RD_NO_MODAL_DIALOGS" } ######################################################################## : "${RD_TAKE_SCREENSHOTS:=false}" taking_screenshots() { is_true "$RD_TAKE_SCREENSHOTS" } ######################################################################## : "${RD_TRACE:=false}" ######################################################################## # When RD_USE_GHCR_IMAGES is true, then all images will be pulled from # ghcr.io instead of docker.io, to avoid hitting the docker hub pull # rate limit. : "${RD_USE_GHCR_IMAGES:=false}" using_ghcr_images() { is_true "$RD_USE_GHCR_IMAGES" } ######################################################################## : "${RD_DELETE_PROFILES:=true}" deleting_profiles() { is_true "$RD_DELETE_PROFILES" } ######################################################################## : "${RD_USE_IMAGE_ALLOW_LIST:=false}" using_image_allow_list() { is_true "$RD_USE_IMAGE_ALLOW_LIST" } ######################################################################## # RD_USE_PROFILE is for internal use. It uses a profile instead of # settings.json to set initial values for WSL integrations and allowed # images list because when settings.json exists the default profile is # ignored. : "${RD_USE_PROFILE:=false}" ######################################################################## # RD_TIMEOUT is for internal use. It is used to configure timeouts for # the `rdctl` command, and should not be set outside of specific # commands. : "${RD_TIMEOUT:=}" if [[ -n $RD_TIMEOUT ]]; then fatal "RD_TIMEOUT should not be set" fi ######################################################################## : "${RD_USE_VZ_EMULATION:=$(bool is_macos)}" using_vz_emulation() { is_true "$RD_USE_VZ_EMULATION" } if using_vz_emulation && ! supports_vz_emulation; then fatal "RD_USE_VZ_EMULATION is not supported on this OS or OS version" fi ######################################################################## : "${RD_USE_WINDOWS_EXE:=$(bool is_windows)}" using_windows_exe() { is_true "$RD_USE_WINDOWS_EXE" } if using_windows_exe && ! is_windows; then fatal "RD_USE_WINDOWS_EXE only works on Windows" fi ######################################################################## if is_unix; then : "${RD_MOUNT_TYPE:=reverse-sshfs}" validate_enum RD_MOUNT_TYPE reverse-sshfs 9p virtiofs if [ "$RD_MOUNT_TYPE" = "virtiofs" ] && ! using_vz_emulation; then fatal "RD_MOUNT_TYPE=virtiofs only works with VZ emulation" fi if [ "$RD_MOUNT_TYPE" = "9p" ] && using_vz_emulation; then fatal "RD_MOUNT_TYPE=9p only works with qemu emulation" fi else : "${RD_MOUNT_TYPE:=}" if [ -n "${RD_MOUNT_TYPE:-}" ]; then fatal "RD_MOUNT_TYPE only works on Linux and macOS" fi fi ######################################################################## : "${RD_9P_CACHE_MODE:=mmap}" validate_enum RD_9P_CACHE_MODE none loose fscache mmap ######################################################################## : "${RD_9P_MSIZE:=128}" ######################################################################## : "${RD_9P_PROTOCOL_VERSION:=9p2000.L}" validate_enum RD_9P_PROTOCOL_VERSION 9p2000 9p2000.u 9p2000.L ######################################################################## : "${RD_9P_SECURITY_MODEL:=none}" validate_enum RD_9P_SECURITY_MODEL passthrough mapped-xattr mapped-file none ######################################################################## # When RD_USE_RAMDISK is true, we will try to set up a temporary ramdisk # for the application profile to make things run faster. This is not # supported on all platforms, but is a no-op on unsupported platforms. # Some test files may override this due to interactions with factory reset. : "${RD_USE_RAMDISK:=false}" # Size of the ramdisk, in gigabytes. If a test requires more space than given, # then ramdisk will be disabled for that test. : "${RD_RAMDISK_SIZE:=12}" using_ramdisk() { is_true "${RD_USE_RAMDISK}" } ######################################################################## # Use RD_PROTECTED_DOT in profile settings for WSL distro names. : "${RD_PROTECTED_DOT:=·}" ######################################################################## # RD_KUBELET_TIMEOUT specifies the number of minutes wait_for_kubelet() # waits before it times out. : "${RD_KUBELET_TIMEOUT:=10}" ######################################################################## # RD_LOCATION specifies the location where Rancher Desktop is installed # system: default system-wide install location shared for all users # user: per-user install location # dist: use the result of `yarn package` in ../dist # dev: dev mode; start app with `cd ..; yarn dev` # "": use first location from the list above that contains the app : "${RD_LOCATION:=}" validate_enum RD_LOCATION system user dist dev "" using_dev_mode() { [ "$RD_LOCATION" = "dev" ] } ######################################################################## # Kubernetes versions # The main Kubernetes version to test. : "${RD_KUBERNETES_VERSION:=1.32.7}" # A secondary Kubernetes version; this is used for testing upgrades. : "${RD_KUBERNETES_ALT_VERSION:=1.31.3}" # RD_K3S_VERSIONS specifies a list of k3s versions. foreach_k3s_version() # can dynamically register a test to run once for each version in the # list. Only versions between RD_K3S_MIN and RD_K3S_MAX (inclusively) # will be used. # # Special values: # "all" will fetch the list of all k3s releases from GitHub # "latest" will fetch the list of latest versions from the release channel : "${RD_K3S_MIN:=1.25.3}" : "${RD_K3S_MAX:=1.99.0}" : "${RD_K3S_VERSIONS:=$RD_KUBERNETES_VERSION}" validate_semver RD_K3S_MIN validate_semver RD_K3S_MAX # Cache expansion of RD_K3S_VERSIONS special versions because they are slow to compute if ! load_var RD_K3S_VERSIONS; then # Fetch "all" or "latest" versions get_k3s_versions for k3s_version in ${RD_K3S_VERSIONS}; do validate_semver k3s_version done save_var RD_K3S_VERSIONS fi ######################################################################## # RD_VPN_TEST_IMAGE specifies the URL used by the split DNS test to access # the private registry. Defaults to empty. Can be set via environment # variable when running tests. : "${RD_VPN_TEST_IMAGE:=}" using_vpn_test_image() { [[ -n $RD_VPN_TEST_IMAGE ]] } ================================================ FILE: bats/tests/helpers/images.bash ================================================ # These images have been mirrored to ghcr.io (using bats/scripts/ghcr-mirror.sh) # to avoid hitting Docker Hub pull limits during testing. # TODO TODO TODO # The python image is huge (10GB across all platforms). We should either pin the # tag, or replace it with a different image for testing, so we don't have to mirror # the images to ghcr.io every time we run the mirror script. # TODO TODO TODO # Any time you add an image here you need to re-run the mirror script! IMAGES=(alpine busybox nginx python python:3.9-slim ruby tonistiigi/binfmt registry:2.8.1) GHCR_REPO=ghcr.io/rancher-sandbox/bats # Create IMAGE_FOO_BAR_TAG=foo/bar:tag variables for IMAGE in "${IMAGES[@]}"; do VAR="IMAGE_$(echo "$IMAGE" | tr '[:lower:]' '[:upper:]' | tr -C '[:alnum:][:space:]' _)" # file may be loaded outside BATS environment if [ "$(type -t using_ghcr_images)" = "function" ] && using_ghcr_images; then eval "$VAR=$GHCR_REPO/$IMAGE" else eval "$VAR=$IMAGE" fi done # shellcheck disable=2034 # The registry image doesn't really need the tag IMAGE_REGISTRY=$IMAGE_REGISTRY_2_8_1 ================================================ FILE: bats/tests/helpers/info.bash ================================================ # shellcheck disable=SC2059 # https://www.shellcheck.net/wiki/SC2059 -- Don't use variables in the printf format string. Use printf '..%s..' "$foo". # This file exists to print information about the configuration. show_info() { # @test # In case the file is loaded as a test: bats tests/helpers/info.bash if [ -z "$RD_HELPERS_LOADED" ]; then load load.bash fi if capturing_logs || taking_screenshots; then rm -rf "$PATH_BATS_LOGS" fi if is_false "${RD_INFO:-true}"; then return fi ( local format="# %s | %s\n" printf "$format" "Install location:" "$RD_LOCATION" printf "$format" "Resources path:" "$PATH_RESOURCES" echo "#" printf "$format" "Container engine:" "$RD_CONTAINER_ENGINE" printf "$format" "Kubernetes version:" "$RD_KUBERNETES_VERSION ($RD_K3S_VERSIONS)" printf "$format" "Mount type:" "$RD_MOUNT_TYPE" if [ "$RD_MOUNT_TYPE" = "9p" ]; then printf "$format" " 9p cache mode:" "$RD_9P_CACHE_MODE" printf "$format" " 9p msize:" "$RD_9P_MSIZE" printf "$format" " 9p protocol version:" "$RD_9P_PROTOCOL_VERSION" printf "$format" " 9p security model:" "$RD_9P_SECURITY_MODEL" fi printf "$format" "Using image allow list:" "$(bool using_image_allow_list)" if is_macos; then printf "$format" "Using VZ emulation:" "$(bool using_vz_emulation)" printf "$format" "Using ramdisk:" "$(bool using_ramdisk)" fi if is_windows; then printf "$format" "Using Windows executables:" "$(bool using_windows_exe)" fi echo "#" printf "$format" "Capturing logs:" "$(bool capturing_logs)" printf "$format" "Tracing execution:" "$(bool is_true "$RD_TRACE")" printf "$format" "Taking screenshots:" "$(bool taking_screenshots)" printf "$format" "Using ghcr.io images:" "$(bool using_ghcr_images)" ) | column -t -s '|' >&3 } ================================================ FILE: bats/tests/helpers/kubernetes.bash ================================================ wait_for_kubelet() { local desired_version=${1:-$RD_KUBERNETES_VERSION} local timeout=$(($(date +%s) + RD_KUBELET_TIMEOUT * 60)) trace "waiting for Kubernetes ${desired_version} to be available" while true; do sleep 1 assert [ "$(date +%s)" -lt "$timeout" ] if ! kubectl get --raw /readyz &>/dev/null; then continue fi # Check that kubelet is Ready run kubectl get node -o jsonpath="{.items[0].status.conditions[?(@.type=='Ready')].status}" if ((status != 0)) || [[ $output != "True" ]]; then continue fi # Make sure the "default" serviceaccount exists if ! kubectl get --namespace default serviceaccount default &>/dev/null; then continue fi # Get kubelet version run kubectl get node -o jsonpath="{.items[0].status.nodeInfo.kubeletVersion}" if ((status != 0)); then continue fi # Turn "v1.23.4+k3s1" into "1.23.4" local version=${output#v} version=${version%+*} if [ "$version" == "$desired_version" ]; then return 0 fi done } # unwrap_kube_list removes the "List" wrapper from the JSON in $output if .kind is "List". # Returns an error if the number of .items in the List isn't exactly 1. unwrap_kube_list() { local json=$output run jq_output '.kind' assert_success if [[ $output == "List" ]]; then run jq --raw-output '.items | length' <<<"$json" assert_success assert_output "1" run jq --raw-output '.items[0]' <<<"$json" assert_success json=$output fi echo "$json" } assert_kube_deployment_available() { local jsonpath="jsonpath={.status.conditions[?(@.type=='Available')].status}" run --separate-stderr kubectl get deployment "$@" --output "$jsonpath" assert_success assert_output "True" } wait_for_kube_deployment_available() { trace "waiting for deployment $*" try assert_kube_deployment_available "$@" } assert_pod_containers_are_running() { run kubectl get pod "$@" --output json assert_success # Make sure the query returned just a single pod run unwrap_kube_list assert_success # Confirm that **all** containers of the pod are in "running" state run jq_output '[.status.containerStatuses[].state | keys] | add | unique | .[]' assert_success assert_output "running" } traefik_ip() { local jsonpath='jsonpath={.status.loadBalancer.ingress[0].ip}' run --separate-stderr kubectl get service traefik --namespace kube-system --output "$jsonpath" assert_success assert_output echo "$output" } traefik_hostname() { if is_windows; then # BUG BUG BUG # Currently the service ip address is not routable from the host # https://github.com/rancher-sandbox/rancher-desktop/issues/6934 # BUG BUG BUG # local ip # ip=$(traefik_ip) # echo "${ip}.sslip.io" # caller must have called `skip_unless_host_ip` output=$HOST_IP assert_output echo "${HOST_IP}.sslip.io" else echo "localhost" fi } wait_for_traefik() { try traefik_ip } get_k3s_versions() { if [[ $RD_K3S_VERSIONS == "all" ]]; then # filter out duplicates; RD only supports the latest of +k3s1, +k3s2, etc. RD_K3S_VERSIONS=$( gh api /repos/k3s-io/k3s/releases --paginate --jq '.[].tag_name' | grep -E '^v1\.[0-9]+\.[0-9]+\+k3s[0-9]+$' | sed -E 's/v([^+]+)\+.*/\1/' | sort --unique --version-sort ) fi if [[ $RD_K3S_VERSIONS == "latest" ]]; then RD_K3S_VERSIONS=$( curl --silent --fail https://update.k3s.io/v1-release/channels | jq --raw-output '.data[] | select(.name | test("^v[0-9]+\\.[0-9]+$")).latest' | sed -E 's/v([^+]+)\+.*/\1/' ) fi } ================================================ FILE: bats/tests/helpers/kubernetes.bats ================================================ load '../helpers/load' : "${RD_INFO:=false}" @test 'unwrap_kube_list: no list' { run echo '{"kind": "Pod"}' assert_success run unwrap_kube_list assert_success run jq_output .kind assert_success assert_output Pod } @test 'unwrap_kube_list: no items' { run echo '{"kind": "List"}' assert_success run unwrap_kube_list assert_failure } @test 'unwrap_kube_list: one item' { run echo '{"kind": "List", "items": [{"kind": "Pod"}]}' assert_success run unwrap_kube_list assert_success run jq_output .kind assert_success assert_output Pod } @test 'unwrap_kube_list: two items' { run echo '{"kind": "List", "items": [{"kind": "Pod"},{"kind": "Pod"}]}' assert_success run unwrap_kube_list assert_failure } @test 'unwrap_kube_list: not JSON' { run echo 'Some random error message' assert_success run unwrap_kube_list assert_failure } ================================================ FILE: bats/tests/helpers/load.bash ================================================ set -o errexit -o nounset -o pipefail # Make sure run() will execute all functions with errexit enabled export BATS_RUN_ERREXIT=1 # RD_HELPERS_LOADED is set when bats/helpers/load.bash has been loaded RD_HELPERS_LOADED=1 absolute_path() { ( cd "$1" pwd ) } PATH_BATS_HELPERS=$(absolute_path "$(dirname "${BASH_SOURCE[0]}")") PATH_BATS_ROOT=$(absolute_path "$PATH_BATS_HELPERS/../..") PATH_BATS_LOGS=$PATH_BATS_ROOT/logs # RD_TEST_FILENAME is relative to tests/ and strips the .bats extension, # e.g. "registry/creds" for ".../bats/tests/registry/creds.bats" RD_TEST_FILENAME=${BATS_TEST_FILENAME#"$PATH_BATS_ROOT/tests/"} RD_TEST_FILENAME=${RD_TEST_FILENAME%.bats} # Use fatal() to abort loading helpers; don't run any tests fatal() { local fd=2 # fd 3 might not be open if we're not fully under bats yet; detect that. [[ -e /dev/fd/3 ]] && fd=3 echo " $1" >&$fd # Print (ugly) stack trace if we are outside any @test function if [ -z "${BATS_SUITE_TEST_NUMBER:-}" ]; then echo >&$fd local frame=0 while caller $frame >&$fd; do ((frame++)) done fi exit 1 } source "$PATH_BATS_ROOT/bats-support/load.bash" source "$PATH_BATS_ROOT/bats-assert/load.bash" source "$PATH_BATS_ROOT/bats-file/load.bash" source "$PATH_BATS_HELPERS/os.bash" source "$PATH_BATS_HELPERS/utils.bash" source "$PATH_BATS_HELPERS/snapshots.bash" # kubernetes.bash has no load-time dependencies source "$PATH_BATS_HELPERS/kubernetes.bash" # defaults.bash uses is_windows() from os.bash and # validate_enum() and is_true() from utils.bash. # get_k3s_versions from kubernetes.bash. source "$PATH_BATS_HELPERS/defaults.bash" # images.bash uses using_ghcr_images() from defaults.bash source "$PATH_BATS_HELPERS/images.bash" # paths.bash uses RD_LOCATION from defaults.bash source "$PATH_BATS_HELPERS/paths.bash" # commands.bash uses is_containerd() from defaults.bash, # is_windows() etc from os.bash, # and PATH_* variables from paths.bash source "$PATH_BATS_HELPERS/commands.bash" # profile.bash uses is_xxx() from os.bash source "$PATH_BATS_HELPERS/profile.bash" # vm.bash uses various PATH_* variables from paths.bash, # rdctl from commands.bash, and jq_output from utils.bash source "$PATH_BATS_HELPERS/vm.bash" # Add BATS helper executables to $PATH. On Windows, we use the Linux version # from WSL. export PATH="$PATH_BATS_ROOT/bin/${OS/windows/linux}:$PATH" # If called from foo() this function will call local_foo() if it exist. call_local_function() { local func func="local_$(calling_function)" if [ "$(type -t "$func")" = "function" ]; then "$func" fi } setup_file() { # We require bash 4; bash 3.2 (as shipped by macOS) seems to have # compatibility issues. if semver_gt 4.0.0 "$(semver "$BASH_VERSION")"; then fail "Bash 4.0.0 is required; you have $BASH_VERSION" fi # We currently use a submodule that provides BATS 1.10; we do not test # against any other copy of BATS (and therefore only support the version in # that submodule). bats_require_minimum_version 1.10.0 # Ideally this should be printed only when using the tap formatter, # but I don't see a way to check for this. echo "# ===== $RD_TEST_FILENAME =====" >&3 # local_setup_file may override RD_USE_RAMDISK call_local_function setup_ramdisk } teardown_file() { capture_logs local shutdown=false if is_linux || is_windows; then # On Linux & Windows if we don't shutdown Rancher Desktop bats tests don't terminate. shutdown=true elif using_dev_mode; then # In dev mode, we also need to shut down. shutdown=true elif using_ramdisk; then # When using a ramdisk, we need to shut down to clean up. shutdown=true fi if is_true $shutdown; then rdctl shutdown || : fi teardown_ramdisk call_local_function } setup() { if [ "${BATS_SUITE_TEST_NUMBER}" -eq 1 ] && [ "$RD_TEST_FILENAME" != "helpers/info.bash" ]; then source "$PATH_BATS_HELPERS/info.bash" show_info echo "#" fi call_local_function } teardown() { if [ -z "$BATS_TEST_SKIPPED" ] && [ -z "$BATS_TEST_COMPLETED" ]; then capture_logs take_screenshot fi call_local_function } ================================================ FILE: bats/tests/helpers/os.bash ================================================ # https://www.shellcheck.net/wiki/SC2120 -- disabled due to complaining about not referencing arguments that are optional on functions is_platformName # shellcheck disable=SC2120 UNAME=$(uname) ARCH=$(uname -m) ARCH=${ARCH/arm64/aarch64} case $UNAME in Darwin) # OS matches the directory name of the PATH_RESOURCES directory, # so uses "darwin" and not "macos". OS=darwin ;; Linux) if [[ $(uname -a) =~ microsoft ]]; then OS=windows else OS=linux fi ;; *) echo "Unexpected uname: $UNAME" >&2 exit 1 ;; esac is_linux() { if [ -z "${1:-}" ]; then test "$OS" = linux else test "$OS" = linux -a "$ARCH" = "$1" fi } is_macos() { if [ -z "${1:-}" ]; then test "$OS" = darwin else test "$OS" = darwin -a "$ARCH" = "$1" fi } is_windows() { if [ -z "${1:-}" ]; then test "$OS" = windows else test "$OS" = windows -a "$ARCH" = "$1" fi } is_unix() { ! is_windows "$@" } skip_on_windows() { if is_windows; then skip "${1:-This test is not applicable on Windows.}" fi } skip_on_unix() { if is_unix; then skip "${1:-This test is not applicable on macOS/Linux.}" fi } needs_port() { local port=$1 if is_linux; then if [ "$(cat /proc/sys/net/ipv4/ip_unprivileged_port_start)" -gt "$port" ]; then # Run sudo non-interactive, so don't prompt for password run sudo -n sh -c "echo $port > /proc/sys/net/ipv4/ip_unprivileged_port_start" if ((status > 0)); then skip "net.ipv4.ip_unprivileged_port_start must be $port or less" fi fi fi } sudo_needs_password() { # Check if we can run /usr/bin/true (or /bin/true) without requiring a password run sudo --non-interactive --reset-timestamp true ((status != 0)) } supports_vz_emulation() { if ! is_macos; then return 1 fi [[ -n ${_RD_SUPPORTS_VZ_EMULATION:-} ]] || load_var _RD_SUPPORTS_VZ_EMULATION || true if [[ -z ${_RD_SUPPORTS_VZ_EMULATION:-} ]]; then local version version=$(semver "$(/usr/bin/sw_vers -productVersion)") trace "macOS version is $version" if semver_gte "$version" 13.3.0; then _RD_SUPPORTS_VZ_EMULATION=true elif [[ $ARCH == x86_64 ]] && semver_gte "$version" 13.0.0; then # Versions 13.0.x .. 13.2.x work only on x86_64, not aarch64 _RD_SUPPORTS_VZ_EMULATION=true else _RD_SUPPORTS_VZ_EMULATION=false fi save_var _RD_SUPPORTS_VZ_EMULATION fi is_true "${_RD_SUPPORTS_VZ_EMULATION}" } ================================================ FILE: bats/tests/helpers/paths.bash ================================================ # PATH_BATS_ROOT, PATH_BATS_LOGS, and PATH_BATS_HELPERS are already set by load.bash PATH_REPO_ROOT=$(absolute_path "$PATH_BATS_ROOT/..") inside_repo_clone() { [ -d "$PATH_REPO_ROOT/pkg/rancher-desktop" ] } set_path_resources() { local system=$1 local user=$2 local dist=$3 local subdir=$4 local fd=3 if [[ ! -e /dev/fd/3 ]]; then fd=2 fi if [ -z "${RD_LOCATION:-}" ]; then if [ -d "$system" ]; then RD_LOCATION=system elif [ -d "$user" ]; then RD_LOCATION=user elif [ -d "$dist" ]; then RD_LOCATION=dist elif inside_repo_clone; then RD_LOCATION=dev else ( echo "Couldn't locate Rancher Desktop in" echo "- \"$system\"" echo "- \"$user\"" echo "- \"$dist\"" echo "and 'yarn dev' is unavailable outside repo clone" ) >&$fd exit 1 fi fi if using_dev_mode; then if is_windows; then fatal "yarn operation not yet implemented for Windows" fi PATH_RESOURCES="$PATH_REPO_ROOT/resources" else PATH_RESOURCES="${!RD_LOCATION}/${subdir}" fi if [ ! -d "$PATH_RESOURCES" ]; then fatal "App resource directory '$PATH_RESOURCES' does not exist" fi } if is_macos; then PATH_APP_HOME="$HOME/Library/Application Support/rancher-desktop" PATH_CONFIG="$HOME/Library/Preferences/rancher-desktop" PATH_CACHE="$HOME/Library/Caches/rancher-desktop" PATH_LOGS="$HOME/Library/Logs/rancher-desktop" PATH_EXTENSIONS="$PATH_APP_HOME/extensions" LIMA_HOME="$PATH_APP_HOME/lima" PATH_SNAPSHOTS="$PATH_APP_HOME/snapshots" PATH_CONTAINERD_SHIMS="$PATH_APP_HOME/containerd-shims" ELECTRON_DIST_ARCH="mac" if is_macos aarch64; then ELECTRON_DIST_ARCH="mac-arm64" fi set_path_resources \ "/Applications/Rancher Desktop.app" \ "$HOME/Applications/Rancher Desktop.app" \ "$PATH_REPO_ROOT/dist/$ELECTRON_DIST_ARCH/Rancher Desktop.app" \ "Contents/Resources/resources" fi if is_linux; then PATH_APP_HOME="$HOME/.local/share/rancher-desktop" PATH_CONFIG="$HOME/.config/rancher-desktop" PATH_CACHE="$HOME/.cache/rancher-desktop" PATH_LOGS="$PATH_APP_HOME/logs" PATH_EXTENSIONS="$PATH_APP_HOME/extensions" LIMA_HOME="$PATH_APP_HOME/lima" PATH_SNAPSHOTS="$PATH_APP_HOME/snapshots" PATH_CONTAINERD_SHIMS="$PATH_APP_HOME/containerd-shims" set_path_resources \ "/opt/rancher-desktop" \ "$HOME/opt/rancher-desktop" \ "$PATH_REPO_ROOT/dist/linux-unpacked" \ "resources/resources" fi wslpath_from_win32_env() { # The cmd.exe _sometimes_ returns an empty string when invoked in a subshell # wslpath "$(cmd.exe /c "echo %$1%" 2>/dev/null)" | tr -d "\r" # Let's see if powershell.exe avoids this issue wslpath "$(powershell.exe -Command "Write-Output \${Env:$1}")" | tr -d "\r" } if is_windows; then LOCALAPPDATA="$(wslpath_from_win32_env LOCALAPPDATA)" PROGRAMFILES="$(wslpath_from_win32_env ProgramFiles)" SYSTEMROOT="$(wslpath_from_win32_env SystemRoot)" PATH_APP_HOME="$LOCALAPPDATA/rancher-desktop" PATH_CONFIG="$LOCALAPPDATA/rancher-desktop" PATH_CACHE="$PATH_APP_HOME/cache" PATH_LOGS="$PATH_APP_HOME/logs" PATH_DISTRO="$PATH_APP_HOME/distro" PATH_DISTRO_DATA="$PATH_APP_HOME/distro-data" PATH_EXTENSIONS="$PATH_APP_HOME/extensions" PATH_SNAPSHOTS="$PATH_APP_HOME/snapshots" PATH_CONTAINERD_SHIMS="$PATH_APP_HOME/containerd-shims" set_path_resources \ "$PROGRAMFILES/Rancher Desktop" \ "$LOCALAPPDATA/Programs/Rancher Desktop" \ "$PATH_REPO_ROOT/dist/win-unpacked" \ "resources/resources" fi PATH_CONFIG_FILE="$PATH_CONFIG/settings.json" USERPROFILE=$HOME if using_windows_exe; then USERPROFILE="$(wslpath_from_win32_env USERPROFILE)" fi host_path() { local path=$1 if using_windows_exe; then path=$(wslpath -w "$path") fi echo "$path" } ================================================ FILE: bats/tests/helpers/profile.bash ================================================ case $OS in darwin) PROFILE_SYSTEM_DEFAULTS=/Library/Preferences/io.rancherdesktop.profile.defaults.plist PROFILE_SYSTEM_LOCKED=/Library/Preferences/io.rancherdesktop.profile.locked.plist PROFILE_USER_DEFAULTS="${HOME}${PROFILE_SYSTEM_DEFAULTS}" PROFILE_USER_LOCKED="${HOME}${PROFILE_SYSTEM_LOCKED}" ;; linux) PROFILE_SYSTEM_DEFAULTS=/etc/rancher-desktop/defaults.json PROFILE_SYSTEM_LOCKED=/etc/rancher-desktop/locked.json PROFILE_USER_DEFAULTS="${HOME}/.config/rancher-desktop.defaults.json" PROFILE_USER_LOCKED="${HOME}/.config/rancher-desktop.locked.json" ;; windows) PROFILE='Software\Policies\Rancher Desktop' PROFILE_SYSTEM_DEFAULTS="HKLM\\${PROFILE}\\Defaults" PROFILE_SYSTEM_LOCKED="HKLM\\${PROFILE}\\Locked" PROFILE_USER_DEFAULTS="HKCU\\${PROFILE}\\Defaults" PROFILE_USER_LOCKED="HKCU\\${PROFILE}\\Locked" # The legacy profiles (for both system and user) are supported for backward # compatibility with Rancher Desktop 1.8.x. For BATS purposes the legacy # user profiles have the advantage of being writable without admin rights. PROFILE='Software\Rancher Desktop\Profile' PROFILE_SYSTEM_LEGACY_DEFAULTS="HKLM\\${PROFILE}\\Defaults" PROFILE_SYSTEM_LEGACY_LOCKED="HKLM\\${PROFILE}\\Locked" PROFILE_USER_LEGACY_DEFAULTS="HKCU\\${PROFILE}\\Defaults" PROFILE_USER_LEGACY_LOCKED="HKCU\\${PROFILE}\\Locked" ;; esac PROFILE_SYSTEM=system PROFILE_SYSTEM_LEGACY=system-legacy PROFILE_USER=user PROFILE_USER_LEGACY=user-legacy PROFILE_DEFAULTS=defaults PROFILE_LOCKED=locked # Default location is a writable user location if is_windows; then PROFILE_LOCATION=$PROFILE_USER_LEGACY else PROFILE_LOCATION=$PROFILE_USER fi PROFILE_TYPE=$PROFILE_DEFAULTS # profile_location is a registry key on Windows, or a filename on macOS and Linux. profile_location() { local profile profile=$(to_upper "profile_${PROFILE_LOCATION}_${PROFILE_TYPE}" | tr - _) echo "${!profile}" } # Execute command for each profile foreach_profile() { local locations=("$PROFILE_SYSTEM" "$PROFILE_USER") if is_windows; then locations+=("$PROFILE_SYSTEM_LEGACY" "$PROFILE_USER_LEGACY") fi local PROFILE_LOCATION PROFILE_TYPE for PROFILE_LOCATION in "${locations[@]}"; do for PROFILE_TYPE in "$PROFILE_DEFAULTS" "$PROFILE_LOCKED"; do "$@" done done } # Check if profile exists profile_exists() { case $OS in darwin | linux) [[ -f $(profile_location) ]] ;; windows) profile_reg query &>/dev/null ;; esac } # Create empty profile create_profile() { case $OS in darwin) profile_plutil -create xml1 ;; linux) local filename filename=$(profile_location) profile_sudo mkdir -p "$(dirname "$filename")" echo "{}" | profile_cat "$filename" ;; windows) # Make sure any old profile data at this location is removed run profile_reg delete "." assert_nothing # Create subkey so that profile_exists returns true now profile_reg add "." ;; esac } # Completely remove the profile. Ignores error if profile doesn't exist delete_profile() { if deleting_profiles; then case $OS in darwin | linux) run profile_sudo rm -f "$(profile_location)" assert_nothing ;; windows) run profile_reg delete "." assert_nothing ;; esac fi } # Export/copy profile to a directory export_profile() { local dir=$1 if profile_exists; then local export="${dir}/profile.${PROFILE_LOCATION}.${PROFILE_TYPE}" case $OS in darwin | linux) local filename filename=$(profile_location) # Keep .plist or .json file extension cp "$filename" "${export}.${filename##*.}" ;; windows) export="$(wslpath -w "${export}.reg")" profile_reg export "${export}" /y ;; esac fi } # Set a profile setting to a boolean; value must be "true" or "false" # The profile must exist before calling this function. add_profile_bool() { local setting=$1 local value=$2 assert profile_exists case $OS in darwin) profile_plutil -replace "$setting" -bool "$value" ;; linux) profile_jq ".${setting} = ${value}" ;; windows) if [[ $value == true ]]; then profile_reg add "$setting" /t REG_DWORD /d 1 else profile_reg add "$setting" /t REG_DWORD /d 0 fi ;; esac } # Set a profile setting to an integer. # The profile must exist before calling this function. add_profile_int() { local setting=$1 local value=$2 assert profile_exists case $OS in darwin) profile_plutil -replace "$setting" -integer "$value" ;; linux) profile_jq ".${setting} = ${value}" ;; windows) profile_reg add "$setting" /t REG_DWORD /d "$value" ;; esac } # Set a profile setting to a string. # The profile must exist before calling this function. add_profile_string() { local setting=$1 local value=$2 assert profile_exists case $OS in darwin) profile_plutil -replace "$setting" -string "$value" ;; linux) profile_jq ".${setting} = $(json_string "$value")" ;; windows) profile_reg add "$setting" /t REG_SZ /d "$value" ;; esac } # Set a profile setting to a list of strings, replacing any existing elements. # The profile must exist before calling this function. add_profile_list() { local elem local setting=$1 shift assert profile_exists case $OS in darwin) profile_plutil -replace "$setting" -array for elem in "$@"; do profile_plutil -insert "$setting" -string "$elem" -append done ;; linux) profile_jq ".${setting} = []" for elem in "$@"; do profile_jq ".${setting} += [$(json_string "$elem")]" done ;; windows) # TODO: what happens when the values contain whitespace or quote characters? profile_reg add "$setting" /t REG_MULTI_SZ /d "$(join_map '\0' echo "$@")" ;; esac } # Remove a key or named value from the profile. # Use a trailing dot to specify that the setting points to a key, e.g. "foo.bar.". # It only makes a difference on Windows but will work on all platforms. remove_profile_entry() { local setting=$1 assert profile_exists case $OS in darwin) profile_plutil -remove "${setting%.}" ;; linux) # This relies on `null` not being a valid setting value. profile_jq " if (try .${setting%.}) | type == \"null\" then error(\"setting ${setting%.} not found\") else del(.${setting%.}) end " ;; windows) profile_reg delete "$setting" ;; esac } ################################################################################ # functions defined below this line are implementation detail and should not # be called directly from any tests. ################################################################################ # Returns number of setting segments (separated by dots), e.g. foo.bar.baz returns 3 count_setting_segments() { echo "${1//./$'\n'}" | wc -l } # Usage: profile_jq $expr # # Applies $expr against the profile and update it in-place. profile_jq() { local expr=$1 local filename filename=$(profile_location) assert_file_exists "$filename" # Need to use a temp file to avoid truncating the file before it has been read. jq "$expr" "$filename" | profile_cat "${filename}.tmp" profile_sudo mv "${filename}.tmp" "$filename" } # Usage: profile_plutil $action $options # # For -insert|-replace|-remove actions it will make sure all higher level # dictionaries are created first because plutil doesn't do it by itself. profile_plutil() { local action=$1 # Make sure all the dictionaries for the setting path exist if [[ $action =~ ^-insert|-replace|-remove$ ]]; then local setting=$2 local count count=$(count_setting_segments "$setting") if ((count > 1)); then local index for index in $(seq $((count - 1))); do local keypath keypath=$(echo "$setting" | cut -d . -f 1-"$index") # Ignore error if dictionary already exists profile_sudo plutil -insert "$keypath" -dictionary "$(profile_location)" || : done fi fi profile_sudo plutil "$@" "$(profile_location)" } # Usage: profile_reg $action $options # or: profile_reg add|delete $setting $options # # Determines the $reg_key from both the profile_location() and the $setting. # Setting `foo.bar.baz` means `foo\bar` is the reg_subkey, and `baz` is the value name. # # Special case `foo.bar.` is used only for "delete" action and specifies `foo\bar` # as the subkey to be deleted (including all values under the key). profile_reg() { local action=$1 shift local reg_key reg_key=$(profile_location) if [[ $action =~ ^add|delete$ ]]; then local setting=$1 shift local count count=$(count_setting_segments "$setting") if ((count > 1)); then local reg_subkey reg_subkey=$(echo "$setting" | cut -d . -f 1-"$((count - 1))") # reg_key uses backslashes instead of dot separators reg_key="${reg_key}\\${reg_subkey//./\\}" fi local reg_value_name reg_value_name=$(echo "$setting" | cut -d . -f "$count") # reg_value_name may be empty when deleting a registry key instead of a named value if [[ -n $reg_value_name ]]; then # turn protected dots back into regular dots again set - /v "${reg_value_name//$RD_PROTECTED_DOT/.}" "$@" fi # Delete entries (and overwrite existing ones) without prompt set - "$@" /f fi reg.exe "$action" "$reg_key" "$@" } profile_sudo() { # TODO How can we make this work on Windows? if [[ $PROFILE_LOCATION == system ]]; then sudo -n "$@" else "$@" fi } profile_cat() { profile_sudo tee "$1" >/dev/null } ensure_profile_is_deleted() { delete_profile if profile_exists; then fatal "Cannot delete $(profile_location)" fi } # Only run this once per test file. It cannot be part of setup_file() because # we want to be able to call fatal() and skip the rest of the tests. if [[ -z ${BATS_SUITE_TEST_NUMBER:-} ]] && deleting_profiles; then foreach_profile ensure_profile_is_deleted fi ================================================ FILE: bats/tests/helpers/snapshots.bash ================================================ delete_all_snapshots() { run rdctl snapshot list --json assert_success # On Windows, executing native Windows executables consumes stdin. # https://github.com/microsoft/WSL/issues/10429 # Work around the issue by using `run` to populate `${lines[@]}` ahead of # time, so that we don't need the buffer during the loop. run jq_output .name assert_success local name for name in "${lines[@]}"; do rdctl snapshot delete "$name" done run rdctl snapshot list assert_success assert_output --partial 'No snapshots' } ================================================ FILE: bats/tests/helpers/utils.bash ================================================ to_lower() { echo "$@" | tr '[:upper:]' '[:lower:]' } to_upper() { echo "$@" | tr '[:lower:]' '[:upper:]' } is_true() { # case-insensitive check; false values: '', '0', 'no', and 'false' local value value=$(to_lower "$1") [[ ! $value =~ ^(0|no|false)?$ ]] } is_false() { ! is_true "$1" } bool() { if "$@"; then echo "true" else echo "false" fi } # Ensure that the variable contains a valid value, e.g. # `validate_enum VAR value1 value2` validate_enum() { local var=$1 shift for value in "$@"; do if [[ ${!var} == "$value" ]]; then return fi done fatal "$var=${!var} is not a valid setting; select from [$*]" } # Ensure that the variable contains a valid semver (major.minor.path) version, e.g. # `validate_semver RD_K3S_MAX` validate_semver() { local var=$1 if ! semver_is_valid "${!var}"; then fatal "$var=${!var} is not a valid semver value (major.minor.patch)" fi } assert_nothing() { # This is a no-op, used to show that run() has been used to continue the # test even when the command failed, but the failure itself is ignored. true } ######################################################################## assert=assert refute=refute before() { local assert=refute local refute=assert "$@" } refute_success() { assert_failure } refute_failure() { assert_success } refute_not_exists() { assert_exists "$@" } refute_file_exists() { assert_file_not_exists "$@" } refute_file_contains() { assert_file_not_contains "$@" } ######################################################################## # Convert raw string into properly quoted JSON string json_string() { echo -n "$1" | jq --raw-input --raw-output @json } # Join list elements by separator after converting them via the mapping function # Examples: # join_map "/" echo usr local bin => usr/local/bin # join_map ", " json_string a b\ c\"d\\e f => "a", "b c\"d\\e", "f" join_map() { local sep=$1 local map=$2 shift 2 local elem local result="" for elem in "$@"; do elem=$(eval "$map" '"$elem"') if [[ -z $result ]]; then result=$elem else result="${result}${sep}${elem}" fi done echo "$result" } # Run jq on the current $output # Note that when capturing $output, you may need to use `run --separate-stderr` # to avoid also capturing stderr and ending up with invalid JSON. jq_output() { local json=$output run jq --raw-output "$@" <<<"${json}" if [[ -n $output ]]; then echo "$output" if [[ $output == null ]]; then status=1 fi elif ((status == 0)); then # The command succeeded, so we should be able to run it again without error # If the jq command emitted a newline, then we want to emit a newline too. if [ "$(jq --raw-output "$@" <<<"${json}" | wc -c)" -gt 0 ]; then echo "" fi fi return "$status" } # semver returns the first semver version from its first argument (which may be multiple lines). # It does not include pre-release markers or build ids. # It will match major.minor, or even just major if it can't find major.minor.patch. # The returned version will always be a major.minor.patch string. # Each part will have leading zeros removed. # semver will fail when the input contains no number. semver() { local input=$1 local semver semver=$(awk 'match($0, /([0-9]+\.[0-9]+\.[0-9]+)/) {print substr($0, RSTART, RLENGTH); exit}' <<<"$input") if [[ -z $semver ]]; then semver=$(awk 'match($0, /([0-9]+\.[0-9]+)/) {print substr($0, RSTART, RLENGTH); exit}' <<<"$input") fi if [[ -z $semver ]]; then semver=$(awk 'match($0, /([0-9]+)/) {print substr($0, RSTART, RLENGTH); exit}' <<<"$input") fi if [[ -z $semver ]]; then return 1 fi until [[ $semver =~ \..+\. ]]; do semver="${semver}.0" done sed -E 's/^0*([0-9])/\1/; s/\.0*([0-9])/.\1/g' <<<"$semver" } # Check if the argument is a valid 3-tuple version number with no leading 0s and no newlines semver_is_valid() { [[ ! $1 =~ $'\n' ]] && grep -q -E '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' <<<"$1" } # All semver comparison functions will return false when called without any argument # and return true when called with just a single argument. # semver_eq checks that all specified arguments are equal to each other. # (semver_eq and semver_neq don't really depend on the arguments being versions). # `A = B = C` semver_eq() { [[ $# -gt 0 ]] && [[ $(printf "%s\n" "$@" | sort --unique | wc -l) -eq 1 ]] } # semver_neq checks that all arguments are unique. `semver_neq A B C` is not the same as # `A ≠ B ≠ C` because semver_neq will also return a failure if `A = C`. # `(A ≠ B) & (A ≠ C) & (B ≠ C)` semver_neq() { [[ $# -gt 0 ]] && printf "%s\n" "$@" | sort | sort --check=silent --unique } # `A ≤ B ≤ C` semver_lte() { [[ $# -gt 0 ]] && printf "%s\n" "$@" | sort --check=silent --version-sort } # `A < B < C` semver_lt() { [[ $# -gt 0 ]] && semver_lte "$@" && semver_neq "$@" } # `A ≥ B ≥ C` semver_gte() { [[ $# -gt 0 ]] && printf "%s\n" "$@" | sort --check=silent --reverse --version-sort } # `A > B > C` semver_gt() { [[ $# -gt 0 ]] && semver_gte "$@" && semver_neq "$@" } ######################################################################## get_setting() { run rdctl api /settings assert_success jq_output "$@" } this_function() { echo "${FUNCNAME[1]}" } calling_function() { echo "${FUNCNAME[2]}" } # Write a comment to the TAP stream. # Set CALLER to print a calling function higher up in the call stack. comment() { local prefix="" if is_true "$RD_TRACE"; then local caller="${CALLER:-$(calling_function)}" prefix="($(date -u +"%FT%TZ"): ${caller}): " fi local line while IFS= read -r line; do if [[ -e /dev/fd/3 ]]; then printf "# %s%s\n" "$prefix" "$line" >&3 else printf "# %s%s\n" "$prefix" "$line" >&2 fi done <<<"$*" } # Write a comment to the TAP stream if RD_TRACE is set. # Set CALLER to print a calling function higher up in the call stack. trace() { if is_true "$RD_TRACE"; then CALLER=${CALLER:-$(calling_function)} comment "$@" fi } # try runs the specified command until it either succeeds, or --max attempts # have been made (with a --delay seconds sleep in between). # # Right now the command is **always** run with --separate-stderr, and stderr # is output after all of stdout. This is subject to change, if we can figure # out a way to detect if the caller used `run --separate-stderr try …` or not. try() { local max=24 local delay=5 while [[ $# -gt 0 ]] && [[ $1 == -* ]]; do case "$1" in --max) max=$2 shift ;; --delay) delay=$2 shift ;; --) shift break ;; *) printf "Usage error: unknown flag '%s'" "$1" >&2 return 1 ;; esac shift done local count=0 while true; do run --separate-stderr "$@" if ((status == 0 || ++count >= max)); then trace "$count/$max tries: $*" break fi sleep "$delay" done echo "$output" if [ -n "${stderr:-}" ]; then echo "$stderr" >&2 fi return "$status" } image_without_tag_as_json_string() { local image=$1 # If the tag looks like a port number and follows something that looks # like a domain name, then don't strip the tag (e.g. foo.io:5000). if [[ ${image##*:} =~ ^[0-9]+(/|$) && ${image%:*} =~ \.[a-z]+$ ]]; then json_string "$image" else json_string "${image%:*}" fi } update_allowed_patterns() { local enabled=$1 shift local patterns patterns=$(join_map ", " image_without_tag_as_json_string "$@") # If the enabled state changes, then the container engine will be restarted. # Record PID of the current daemon process so we can wait for it to be ready again. local pid if [ "$enabled" != "$(get_setting .containerEngine.allowedImages.enabled)" ]; then pid=$(get_service_pid "$CONTAINER_ENGINE_SERVICE") fi rdctl api settings -X PUT --input - <$1`. Will create any parent # directories. create_file() { local dest=$1 # On Windows, avoid creating files from within WSL; this leads to issues # where the WSL view of the filesystem is desynchronized from the Windows # view, so we end up having ghost files that can't be deleted from Windows. if ! is_windows; then mkdir -p "$(dirname "$dest")" cat >"$dest" return fi local contents # Base64 encoded file contents contents="$(base64)" local winParent local winDest winParent="$(wslpath -w "$(dirname "$dest")")" winDest="$(wslpath -w "$dest")" PowerShell.exe -NoProfile -NoLogo -NonInteractive -Command "New-Item -ItemType Directory -ErrorAction SilentlyContinue '$winParent'" || true local command="[IO.File]::WriteAllBytes('$winDest', \$([System.Convert]::FromBase64String('$contents')))" PowerShell.exe -NoProfile -NoLogo -NonInteractive -Command "$command" } # unique_filename /tmp/image .png # will return /tmp/image.png, or /tmp/image_2.png, etc. unique_filename() { local basename=$1 local extension=${2:-} local index=1 local suffix="" while true; do local filename="${basename}${suffix}${extension}" if [[ ! -e $filename ]]; then echo "$filename" return fi suffix="_$((++index))" done } capture_logs() { if capturing_logs && [[ -d $PATH_LOGS ]]; then local logdir logdir=$(unique_filename "${PATH_BATS_LOGS}/${RD_TEST_FILENAME}") mkdir -p "$logdir" # On Linux/macOS, the symlinks to the lima logs might be dangling. # Remove any dangling ones before doing the copy. find -L "${PATH_LOGS}/" -type l \ -exec rm -f -- '{}' ';' \ -exec touch -- '{}' ';' \ -exec echo 'Replaced dangling symlink with empty file:' '{}' ';' cp -LR "${PATH_LOGS}/" "$logdir" echo "${BATS_TEST_DESCRIPTION:-teardown}" >"${logdir}/test_description" # Capture settings.json cp "$PATH_CONFIG_FILE" "$logdir" foreach_profile export_profile "$logdir" fi } take_screenshot() { if taking_screenshots; then local image_path image_path="$(unique_filename "${PATH_BATS_LOGS}/${BATS_SUITE_TEST_NUMBER}-${BATS_TEST_DESCRIPTION}" .png)" mkdir -p "$PATH_BATS_LOGS" if is_macos; then # The terminal app must have "Screen Recording" permission; # otherwise only the desktop background is captured. # -x option means "do not play sound" screencapture -x "$image_path" elif is_linux; then if import -help &1 | grep --quiet -E 'Version:.*Magick'; then # `import` from ImageMagick is available. import -window root "$image_path" elif gm import -help &1 | grep --quiet -E 'Version:.*Magick'; then # GraphicsMagick is installed (its command is `gm`). gm import -window root "$image_path" fi fi fi } skip_unless_host_ip() { if using_windows_exe; then # Make sure the exit code is 0 even when netsh.exe or grep fails, in case errexit is in effect HOST_IP=$(netsh.exe interface ip show addresses 'vEthernet (WSL)' | grep -Po 'IP Address:\s+\K[\d.]+' || :) # The veth interface name changed at some time on Windows 11, so try the new name if the old one doesn't exist if [[ -z $HOST_IP ]]; then HOST_IP=$(netsh.exe interface ip show addresses 'vEthernet (WSL (Hyper-V firewall))' | grep -Po 'IP Address:\s+\K[\d.]+' || :) fi else # TODO determine if the Lima VM has its own IP address HOST_IP="" fi if [[ -z $HOST_IP ]]; then skip "Test requires a routable host ip address" fi } ######################################################################## # Register one or more test commands for each k3s version in RD_K3S_VERSIONS. # Versions can be filtered by RD_K3S_MIN and RD_K3S_MAX. foreach_k3s_version() { local k3s_version for k3s_version in $RD_K3S_VERSIONS; do if semver_lte "$RD_K3S_MIN" "$k3s_version" "$RD_K3S_MAX"; then local cmd for cmd in "$@"; do bats_test_function --description "$cmd $k3s_version" -- _foreach_k3s_version "$k3s_version" "$cmd" done fi done } _foreach_k3s_version() { local RD_KUBERNETES_VERSION=$1 local skip_kubernetes_version skip_kubernetes_version=$(cat "${BATS_FILE_TMPDIR}/skip-kubernetes-version" 2>/dev/null || echo none) if [[ $skip_kubernetes_version == "$RD_KUBERNETES_VERSION" ]]; then skip "All remaining tests for Kubernetes $RD_KUBERNETES_VERSION are skipped" fi "$2" } # Tests can call mark_k3s_version_skipped to skip the rest of the tests within # this iteration of foreach_k3s_version. mark_k3s_version_skipped() { echo "$RD_KUBERNETES_VERSION" >"${BATS_FILE_TMPDIR}/skip-kubernetes-version" } ######################################################################## _var_filename() { # Can't use BATS_SUITE_TMPDIR because it is unset outside of @test functions echo "${BATS_RUN_TMPDIR}/var_$1" } # Save env variables on disk, so they can be reloaded in different tests. # This is mostly useful if calculating the setting takes a long time. # Returns false if any variable was unbound, but will continue saving remaining variables. # `save_var VAR1 VAR2` save_var() { local res=0 local var for var in "$@"; do # Using [[ -v $var ]] requires bash 4.2 but macOS only ships with 3.2 if [ -n "${!var+exists}" ]; then printf "%s=%q\n" "$var" "${!var}" >"$(_var_filename "$var")" else res=1 fi done return $res } # Load env variables saved by `save_var`. Returns an error if any of the variables # had not been saved, but will continue to try to load the remaining variables. # `load_var VAR1 VAR2` load_var() { local res=0 local var for var in "$@"; do local file file=$(_var_filename "$var") if [[ -r $file ]]; then # shellcheck disable=SC1090 # Can't follow non-constant source source "$file" else res=1 fi done return $res } ================================================ FILE: bats/tests/helpers/utils.bats ================================================ # bats file_tags=opensuse load '../helpers/load' : "${RD_INFO:=false}" ######################################################################## local_setup() { COUNTER="${BATS_FILE_TMPDIR}/counter" reset_counter } reset_counter() { echo 0 >"$COUNTER" SECONDS=0 } # Increment counter file. Return success when counter >= max. inc_counter() { local max=${1-9999} local counter=$(($(cat "$COUNTER") + 1)) echo $counter >"$COUNTER" ((counter >= max)) } assert_counter_is() { run cat "${COUNTER}" assert_output "$1" } is() { local expect=$1 # shellcheck disable=SC2086 # we want to split on whitespace run ${BATS_TEST_DESCRIPTION} assert_success assert_output "$expect" } is_quoted() { is "\"$1\"" } succeeds() { # shellcheck disable=SC2086 # we want to split on whitespace run ${BATS_TEST_DESCRIPTION} assert_success } fails() { # shellcheck disable=SC2086 # we want to split on whitespace run ${BATS_TEST_DESCRIPTION} assert_failure } ######################################################################## errexit() { false true } @test 'run() calls functions with errexit enabled' { run errexit assert_failure } ######################################################################## @test 'to_lower Upper and Lower' { is "upper and lower" } @test 'to_lower' { is "" } @test 'to_upper 123+abc' { is "123+ABC" } @test 'to_upper' { is "" } ######################################################################## check_truthiness() { local predicate=$1 local value # test true values for value in 1 true True TRUE yes Yes YES any; do run "$predicate" "$value" "${assert}_success" done # test false values for value in 0 false False FALSE no No NO ''; do run "$predicate" "$value" "${assert}_failure" done } @test 'is_true' { check_truthiness is_true } @test 'is_false' { assert=refute check_truthiness is_false } @test 'bool [ 0 -eq 0 ]' { is true } @test 'bool [ 0 -eq 1 ]' { is false } ######################################################################## @test 'validate_enum OS should pass' { run validate_enum OS darwin linux windows assert_success } @test 'validate_enum FRUIT should fail' { FRUIT=apple run validate_enum FRUIT banana cherry pear assert_failure # Can't check output; it is written using "fatal": # FRUIT=apple is not a valid setting; select from [banana cherry pear] } ######################################################################## @test 'is_xxx' { # Exactly one of the is_xxx functions should return true count=0 for os in linux macos windows; do if "is_$os"; then ((++count)) fi done ((count == 1)) } ######################################################################## get_json_test_data() { # The run/assert silliness is because shellcheck gets confused by direct assignment to $output run echo '{"String":"string", "False":false, "Null":null}' assert_success } @test 'jq_output extracts string value' { get_json_test_data run jq_output .String assert_success assert_output string } @test 'jq_output extracts "false" value' { get_json_test_data run jq_output .False assert_success assert_output false } @test 'jq_output cannot extract "null" value' { get_json_test_data run jq_output .Null assert_failure assert_output null } @test 'jq_output fails when key is not found' { get_json_test_data run jq_output .DoesNotExist assert_failure assert_output null } @test 'jq_output fails on null' { output=null run jq_output .Anything assert_failure assert_output null } @test 'jq_output fails on undefined' { output=undefined run jq_output .Anything assert_failure assert_output --partial "parse error" } @test 'jq_output fails on non-JSON data' { output="This is not JSON" run jq_output .Anything assert_failure assert_output --partial "parse error" } @test 'jq_output does not return a newline when the output is "nothing"' { output="" output=$( jq_output .Anything echo "." ) assert_output "." } @test 'jq_output does return a newline when the output is the empty string' { output='{"Empty": ""}' output=$( jq_output .Empty echo "." ) assert_output $'\n.' } @test 'jq must be version 1.7.1 or newer' { run semver "$(jq --version)" assert_success semver_gte "$output" 1.7.1 } ######################################################################## @test 'semver a1b2.3c4.5.6d7.8.9.0' { is 4.5.6 } @test 'semver a1b2.3c4.5' { is 2.3.0 } @test 'semver a1b2c3' { is 1.0.0 } @test 'semver 1.2.3.4' { is 1.2.3 } @test 'semver 00.00.00' { is 0.0.0 } @test 'semver 000000' { is 0.0.0 } @test 'semver 0.001' { is 0.1.0 } @test 'semver 00100.00200.00300' { is 100.200.300 } @test 'semver ignores dates/times' { run semver "1/1/70 12:00:00 version 7.8" assert_success assert_output 7.8.0 } @test 'semver looks at all lines of the input' { run semver $'Version1: 1.2\nVersion2: 3.4.5' assert_success assert_output 3.4.5 } @test 'semver looks only at the first argument' { run semver 'Version1: 1.2' 'Version2: 3.4.5' assert_success assert_output 1.2.0 } @test 'semver fails when input has no number' { run semver "Hello world" assert_failure } ######################################################################## @test 'semver_is_valid 1.2.3' { succeeds } @test 'semver_is_valid 1.2.3-pre' { fails } @test 'semver_is_valid v1.2.3' { fails } @test 'semver_is_valid 1.2.' { fails } @test 'semver_is_valid 1' { fails } @test 'semver_is_valid 0.0.0' { succeeds } @test 'semver_is_valid 01.2.3' { fails } @test 'semver_is_valid 1.02.3' { fails } @test 'semver_is_valid fails on trailing newline' { run semver_is_valid $'1.2.3\n' assert_failure } ######################################################################## @test 'semver_eq' { fails } @test 'semver_eq 1.2.3' { succeeds } @test 'semver_eq 1.2.3 1.2.3' { succeeds } @test 'semver_eq 1.2.3 4.5.6' { fails } @test 'semver_eq 1.2.3 1.2.3 1.2.3' { succeeds } @test 'semver_eq 1.2.3 1.2.3 4.5.6' { fails } ######################################################################## @test 'semver_neq' { fails } @test 'semver_neq 1.2.3' { succeeds } @test 'semver_neq 1.2.3 1.2.3' { fails } @test 'semver_neq 1.2.3 4.5.6' { succeeds } @test 'semver_neq 4.5.6 1.2.3' { succeeds } @test 'semver_neq 1.2.3 4.5.6 1.2.3' { fails } @test 'semver_neq 1.2.3 4.5.6 7.8.9' { succeeds } @test 'semver_neq 4.5.6 7.8.9 1.2.3' { succeeds } ######################################################################## @test 'semver_lt' { fails } @test 'semver_lt 1.2.3' { succeeds } @test 'semver_lt 1.2.3 1.2.3' { fails } @test 'semver_lt 1.2.3 4.5.6' { succeeds } @test 'semver_lt 4.5.6 1.2.3' { fails } @test 'semver_lt 1.2.3 4.5.6 7.8.9' { succeeds } @test 'semver_lt 1.2.3 4.5.6 4.5.6' { fails } ######################################################################## @test 'semver_lte' { fails } @test 'semver_lte 1.2.3' { succeeds } @test 'semver_lte 1.2.3 1.2.3' { succeeds } @test 'semver_lte 1.2.3 4.5.6' { succeeds } @test 'semver_lte 4.5.6 1.2.3' { fails } @test 'semver_lte 1.2.3 4.5.6 4.5.6' { succeeds } @test 'semver_lte 1.2.3 4.5.6 1.2.3' { fails } ######################################################################## @test 'semver_gt' { fails } @test 'semver_gt 1.2.3' { succeeds } @test 'semver_gt 1.2.3 1.2.3' { fails } @test 'semver_gt 1.2.3 4.5.6' { fails } @test 'semver_gt 4.5.6 1.2.3' { succeeds } @test 'semver_gt 7.8.9 4.5.6 1.2.3' { succeeds } @test 'semver_gt 7.8.9 4.5.6 4.5.6' { fails } ######################################################################## @test 'semver_gte' { fails } @test 'semver_gte 1.2.3' { succeeds } @test 'semver_gte 1.2.3 1.2.3' { succeeds } @test 'semver_gte 1.2.3 4.5.6' { fails } @test 'semver_gte 4.5.6 1.2.3' { succeeds } @test 'semver_gte 7.8.9 4.5.6 4.5.6' { succeeds } @test 'semver_gte 7.8.9 4.5.6 7.8.9' { fails } ######################################################################## @test 'this_function' { foo() { this_function } run foo assert_success assert_output foo } @test 'calling_function' { bar() { baz } baz() { calling_function } run bar assert_success assert_output bar } ######################################################################## @test 'call_local_function' { local_func() { echo local_func } func() { call_local_function } run func assert_success assert_output local_func } ######################################################################## @test 'try returns stdout and stderr together' { run try --max 1 sh -c 'echo foo; echo bar >&2; echo baz' trace "output=$output" trace "stderr=${stderr:-}" assert_success # output is currently re-ordered that all stderr follows all stdout # this is subject to change assert_line -n 0 foo assert_line -n 2 bar assert_line -n 1 baz output=${stderr:-} assert_output '' } @test 'try supports --separate-stderr' { run --separate-stderr try --max 1 sh -c 'echo foo; echo bar >&2; echo baz' trace "output=$output" trace "stderr=${stderr:-}" assert_success assert_output $'foo\nbaz' output=$stderr assert_output bar } @test 'try will run command at least once' { run try --max 0 --delay 15 inc_counter assert_failure assert_counter_is 1 # "try" should not have called "sleep 15" at all ((SECONDS < 15)) } @test 'try will stop as soon as the command succeeds' { run try --max 3 --delay 3 inc_counter 2 assert_success assert_counter_is 2 # "try" should have called "sleep 3" exactly once ((SECONDS >= 3)) if ((SECONDS >= 6)); then # maybe slow machine; try again with longer sleep reset_counter run try --max 3 --delay 15 inc_counter 2 assert_success assert_counter_is 2 # "try" should have called "sleep 15" exactly once ((SECONDS >= 15)) ((SECONDS < 30)) fi } @test 'try will return after max retries' { run try --max 3 --delay 3 inc_counter assert_failure assert_counter_is 3 # "try" should have called "sleep 3" exactly twice ((SECONDS >= 6)) if ((SECONDS >= 9)); then # maybe slow machine; try again with longer sleep reset_counter run try --max 3 --delay 15 inc_counter assert_failure assert_counter_is 3 # "try" should have called "sleep 15" exactly twice ((SECONDS >= 30)) ((SECONDS < 45)) fi } ######################################################################## @test 'json_string' { run json_string foo\ bar\"baz\' assert_success assert_output "\"foo bar\\\"baz'\"" } ######################################################################## @test 'join_map echo' { run join_map / echo usr local bin assert_success assert_output usr/local/bin } @test 'join_map false' { run join_map / false usr local bin assert_failure } @test 'join_map json_string' { run join_map ", " json_string true "foo bar" baz:80 assert_success assert_output '"true", "foo bar", "baz:80"' } @test 'join_map empty list' { run join_map / echo assert_success assert_output '' } ######################################################################## @test 'image_without_tag_as_json_string busybox' { is_quoted busybox } @test 'image_without_tag_as_json_string busybox:latest' { is_quoted busybox } @test 'image_without_tag_as_json_string busybox:5000' { is_quoted busybox } @test 'image_without_tag_as_json_string registry.io:5000' { is_quoted registry.io:5000 } @test 'image_without_tag_as_json_string registry.io:5000/busybox' { is_quoted registry.io:5000/busybox } @test 'image_without_tag_as_json_string registry.io:5000/busybox:8080' { is_quoted registry.io:5000/busybox } ######################################################################## @test 'unique_filename without extension' { run unique_filename "$COUNTER" assert_success assert_output "${COUNTER}_2" touch "$output" run unique_filename "$COUNTER" assert_success assert_output "${COUNTER}_3" } @test 'unique_filename with extension' { run unique_filename "$COUNTER" .png assert_success assert_output "${COUNTER}.png" touch "$output" run unique_filename "$COUNTER" .png assert_success assert_output "${COUNTER}_2.png" touch "$output" run unique_filename "$COUNTER" .png assert_success assert_output "${COUNTER}_3.png" } ######################################################################## @test 'save_var existing variables' { FOO=baz BAR=foo save_var FOO BAR } @test 'load_var existing variables' { # shellcheck disable=SC2030 FOO=bar BAR=bar load_var FOO BAR [[ $FOO == baz ]] [[ $BAR == foo ]] } @test 'save_var mix of existing and non-existing variables' { ONE=one TWO=two FAILED=false # Don't use run because it may mask errexit failures save_var ONE DOES_NOT_EXIST TWO || FAILED=true [[ $FAILED == true ]] [[ $ONE == one ]] [[ $TWO == two ]] } @test 'load_var mix of existing and non-existing variables' { DOES_NOT_EXIST=false # Can't use `run` because variable would be sourced in a subshell load_var FOO DOES_NOT_EXIST BAR || DOES_NOT_EXIST=true [[ $DOES_NOT_EXIST == true ]] # shellcheck disable=SC2031 [[ $FOO == baz ]] # shellcheck disable=SC2031 [[ $BAR == foo ]] } ================================================ FILE: bats/tests/helpers/vm.bash ================================================ wait_for_shell() { if is_windows; then try --max 48 --delay 5 rdctl shell grep ID= /etc/os-release if using_systemd; then try --max 24 --delay 5 rdctl shell test -f /var/run/lima-boot-done try --max 24 --delay 5 rdctl shell systemctl is-active rancher-desktop.target try --max 48 --delay 5 rdctl shell sudo systemctl is-system-running --wait fi else # Be at the root directory to avoid issues with limactl automatic # changing to the current directory, which might not exist. pushd / try --max 24 --delay 5 rdctl shell test -f /var/run/lima-boot-done # wait until sshfs mounts are done try --max 12 --delay 5 rdctl shell test -d "$HOME/.rd" popd || : fi } pkill_by_path() { local arg arg=$(readlink -f "$1") if [[ -n $arg ]]; then pkill -f "$arg" fi } clear_iptables_chain() { local chain=$1 local rule wsl sudo iptables -L | awk "/^Chain ${chain}/ {print \$2}" | while IFS= read -r rule; do wsl sudo iptables -X "$rule" done } flush_iptables() { # reset default policies wsl sudo iptables -P INPUT ACCEPT wsl sudo iptables -P FORWARD ACCEPT wsl sudo iptables -P OUTPUT ACCEPT wsl sudo iptables -t nat -F wsl sudo iptables -t mangle -F wsl sudo iptables -F wsl sudo iptables -X } # Helper to eject all existing ramdisk instances on macOS macos_eject_ramdisk() { local mount="$1" run hdiutil info -plist assert_success # shellcheck disable=2154 # $output set by `run` run plutil -convert json -o - - <<<"$output" assert_success # shellcheck disable=2016 # $mount is interpreted by jq, not shell. local expr='.images[]."system-entities"[] | select(."mount-point" == $mount) | ."dev-entry"' run jq_output --arg mount "$mount" "$expr" assert_success if [[ -z $output ]]; then return fi # We don't need to worry about splitting here, it's all /dev/disk* # However, we do need to ensure $output isn't clobbered. # shellcheck disable=2206 local disks=($output) local disk for disk in "${disks[@]}"; do CALLER="$(calling_function):umount" trace "$(umount "$disk" 2>&1 || :)" done for disk in "${disks[@]}"; do CALLER="$(calling_function):hdiutil" trace "$(hdiutil eject "$disk" 2>&1 || :)" done } # Set up the use of a ramdisk for application data, to make things faster. setup_ramdisk() { if ! using_ramdisk; then return fi # Force eject any existing disks. if is_macos; then # Try to eject the disk, if it already exists. macos_eject_ramdisk "$LIMA_HOME" fi local ramdisk_size="${RD_RAMDISK_SIZE}" if ((ramdisk_size < ${RD_FILE_RAMDISK_SIZE:-0})); then local fmt='%s requires %dGB of ramdisk; disabling ramdisk for this file' # shellcheck disable=SC2059 # The string is set the line above. printf -v fmt "$fmt" "$BATS_TEST_FILENAME" "$RD_FILE_RAMDISK_SIZE" printf "RD: %s\n" "$fmt" >>"$BATS_WARNING_FILE" printf "# WARN: %s\n" "$fmt" >&3 return fi if is_macos; then local sectors=$((ramdisk_size * 1024 * 1024 * 1024 / 512)) # Size, in sectors. # hdiutil space-pads the output; strip it. disk="$(hdiutil attach -nomount "ram://$sectors" | xargs echo)" newfs_hfs -v 'Rancher Desktop BATS' "$disk" mkdir -p "$LIMA_HOME" mount -t hfs "$disk" "$LIMA_HOME" CALLER="$(this_function):hdiutil" trace "$(hdiutil info)" CALLER="$(this_function):df" trace "$(df -h)" fi } # Remove any ramdisks teardown_ramdisk() { # We run this even if ramdisk is not in use, in case a previous run had # used ramdisk. if is_macos; then CALLER="$(this_function):hdiutil" trace "$(hdiutil info)" CALLER="$(this_function):df" trace "$(df -h)" macos_eject_ramdisk "$LIMA_HOME" fi } factory_reset() { if [ "$BATS_TEST_NUMBER" -gt 1 ]; then capture_logs fi if using_dev_mode; then if is_unix; then rdctl shutdown || : pkill_by_path "$PATH_REPO_ROOT/node_modules" || : pkill_by_path "$PATH_RESOURCES" || : pkill_by_path "$LIMA_HOME" || : else # TODO: kill `yarn dev` instance on Windows true fi fi if is_windows && wsl true >/dev/null; then wsl sudo ip link delete docker0 || : wsl sudo ip link delete nerdctl0 || : # reset iptables to original state flush_iptables clear_iptables_chain "CNI" clear_iptables_chain "KUBE" fi rdctl reset --factory "$@" setup_ramdisk } # Turn `rdctl start` arguments into `yarn dev` arguments apify_arg() { # TODO this should be done via autogenerated code from command-api.yaml perl -w - "$1" <<'EOF' # don't modify the value part after the first '=' sign ($_, my $value) = split /=/, shift, 2; if (/^--/) { # turn "--virtual-machine.memory-in-gb" into "--virtualMachine.memoryInGb" s/(\w)-(\w)/$1\U$2/g; # fixup acronyms s/memoryInGb/memoryInGB/; s/numberCpus/numberCPUs/; s/--wsl/--WSL/; } print; print "=$value" if $value; EOF } start_container_engine() { local args=( --application.debug --application.updater.enabled=false --kubernetes.enabled=false ) local admin_access=false if [ -n "$RD_CONTAINER_ENGINE" ]; then args+=(--container-engine.name="$RD_CONTAINER_ENGINE") fi if is_unix; then args+=( --application.admin-access="$admin_access" --application.path-management-strategy rcfiles --virtual-machine.memory-in-gb 6 --virtual-machine.mount.type="$RD_MOUNT_TYPE" ) fi if [ "$RD_MOUNT_TYPE" = "9p" ]; then args+=( --experimental.virtual-machine.mount.9p.cache-mode="$RD_9P_CACHE_MODE" --experimental.virtual-machine.mount.9p.msize-in-kib="$RD_9P_MSIZE" --experimental.virtual-machine.mount.9p.protocol-version="$RD_9P_PROTOCOL_VERSION" --experimental.virtual-machine.mount.9p.security-model="$RD_9P_SECURITY_MODEL" ) fi if is_macos; then if using_vz_emulation; then args+=(--virtual-machine.type vz) if is_macos aarch64; then args+=(--virtual-machine.use-rosetta) fi else args+=(--virtual-machine.type qemu) fi fi # TODO containerEngine.allowedImages.patterns and WSL.integrations # TODO cannot be set from the commandline yet image_allow_list="$(bool using_image_allow_list)" registry="docker.io" if using_ghcr_images; then registry="ghcr.io" fi if is_true "${RD_USE_PROFILE:-}"; then if ! profile_exists; then create_profile fi add_profile_int "version" 7 if is_windows; then # Translate any dots in the distro name into $RD_PROTECTED_DOT (e.g. "Ubuntu-22.04") # so that they are not treated as setting separator characters. add_profile_bool "WSL.integrations.${WSL_DISTRO_NAME//./$RD_PROTECTED_DOT}" true fi # TODO Figure out the interaction between RD_USE_PROFILE and RD_USE_IMAGE_ALLOW_LIST! # TODO For now we need to avoid overwriting settings that may already exist in the profile. # add_profile_bool containerEngine.allowedImages.enabled "$image_allow_list" # add_profile_list containerEngine.allowedImages.patterns "$registry" else local wsl_integrations="{}" if is_windows; then wsl_integrations="{\"$WSL_DISTRO_NAME\":true}" fi create_file "$PATH_CONFIG_FILE" <"$PATH_APP_HOME/provisioning/bats.start" else mkdir -p "$LIMA_HOME/_config" cat <"$LIMA_HOME/_config/override.yaml" provision: - mode: system script: | $(sed 's/^/ /') EOF fi } get_container_engine_info() { run ctrctl info echo "$output" assert_success assert_output --partial "Server Version:" } docker_context_exists() { # We don't use docker contexts on Windows if is_windows; then return fi run docker_exe context ls -q assert_success assert_line "$RD_DOCKER_CONTEXT" # Ensure that the context actually exists by reading from the file. run docker_exe context inspect "$RD_DOCKER_CONTEXT" --format '{{ .Name }}' assert_success assert_output "$RD_DOCKER_CONTEXT" } # Check if the VM is using systemd (instead of OpenRC). using_systemd() { [[ -n ${_RD_USING_SYSTEMD:-} ]] || load_var _RD_USING_SYSTEMD || true if [[ -z ${_RD_USING_SYSTEMD:-} ]]; then # `systemctl whoami` contacts the systemd init to check things, so if # it succeeds we're using systemd. On alpine-based systems, the # `systemctl` command would be missing so this still applies. if rdctl shell /usr/bin/systemctl whoami &>/dev/null; then _RD_USING_SYSTEMD=true else _RD_USING_SYSTEMD=false fi save_var _RD_USING_SYSTEMD fi is_true "${_RD_USING_SYSTEMD}" } # Manage a service in the vm. # service_control [--ifstarted] $SERVICE start|stop|restart service_control() { local if_started if [[ ${1:-} == "--ifstarted" ]]; then if_started=$1 shift fi local service=$1 action=$2 if using_systemd; then if [[ -n $ifstarted && $action == restart ]]; then rdsudo systemctl try-restart "$service" else rdsudo systemctl "$action" "$service" fi else # shellcheck disable=2086 # the argument may expand to nothing. rdsudo rc-service ${if_started:-} "$service" "$action" fi } get_service_pid() { local service_name=$1 if using_systemd; then RD_TIMEOUT=10s run rdshell systemctl show --property MainPID --value "$service_name.service" assert_success echo "$output" else RD_TIMEOUT=10s run rdshell sh -c "RC_SVCNAME=$service_name /usr/libexec/rc/bin/service_get_value pidfile" assert_success RD_TIMEOUT=10s rdshell cat "$output" fi } assert_service_pid() { local service_name=$1 local expected_pid=$2 run get_service_pid "$service_name" assert_success assert_output "$expected_pid" } # Check that the given service does not have the given PID. It is acceptable # for the service to not be running. refute_service_pid() { local service_name=$1 local unexpected_pid=$2 run get_service_pid "$service_name" if [ "$status" -eq 0 ]; then refute_output "$unexpected_pid" fi } assert_service_status() { local service_name=$1 local expect=$2 if using_systemd; then local mapped_status case $expect in started) mapped_status=active ;; stopped) mapped_status=inactive ;; *) fail "Status $expect is unsupported" ;; esac RD_TIMEOUT=10s run rdsudo systemctl is-active "$service_name" # `systemctl is-active` returns 0 on active, and non-0 on non-active. if [[ $expect == started ]]; then assert_success fi assert_line "$mapped_status" else RD_TIMEOUT=10s run rdsudo rc-service "$service_name" status # rc-service report non-zero status (3) when the service is stopped if [[ $expect == started ]]; then assert_success fi assert_output --partial "status: ${expect}" fi } wait_for_service_status() { local service_name=$1 local expect=$2 trace "waiting for VM to be available" wait_for_shell trace "waiting for ${service_name} to be ${expect}" try --max 30 --delay 5 assert_service_status "$service_name" "$expect" } wait_for_container_engine() { local CALLER CALLER=$(this_function) trace "waiting for api /settings to be callable" RD_TIMEOUT=10s try --max 30 --delay 5 rdctl api /settings if using_docker; then wait_for_service_status docker started trace "waiting for docker context to exist" try --max 30 --delay 10 docker_context_exists else wait_for_service_status buildkitd started fi trace "waiting for container engine info to be available" try --max 12 --delay 10 get_container_engine_info } # Wait fot the extension manager to be initialized. wait_for_extension_manager() { trace "waiting for extension manager to be ready" # We want to match specific error strings, so we can't use try() directly. local count=0 max=30 message while true; do run --separate-stderr rdctl api /extensions if ((status == 0 || ++count >= max)); then break fi message=$(jq_output .message) output="$message" assert_output "503 Service Unavailable" sleep 10 done trace "$count/$max tries: wait_for_extension_manager" } # See definition of `State` in # pkg/rancher-desktop/backend/backend.ts for an explanation of each state. assert_backend_available() { RD_TIMEOUT=10s run rdctl api /v1/backend_state if ((status == 0)); then run jq_output .vmState case "$output" in ERROR) return 0 ;; STARTED) return 0 ;; DISABLED) return 0 ;; esac fi return 1 } wait_for_backend() { trace "waiting for backend to be available" try --max 60 --delay 10 assert_backend_available } ================================================ FILE: bats/tests/k8s/enable-disable-k8s.bats ================================================ # Test case 8, 13, 22 load '../helpers/load' @test 'factory reset' { factory_reset } verify_k8s_is_running() { wait_for_container_engine wait_for_service_status k3s started } @test 'start rancher desktop with kubernetes enabled' { start_kubernetes wait_for_kubelet verify_k8s_is_running } @test 'disable kubernetes' { rdctl set --kubernetes.enabled=false wait_for_container_engine wait_for_service_status k3s stopped } @test 're-enable kubernetes' { rdctl set --kubernetes.enabled=true wait_for_kubelet verify_k8s_is_running } ================================================ FILE: bats/tests/k8s/foreach-k3s-version.bats ================================================ load '../helpers/load' wait_for_dns() { try assert_pod_containers_are_running \ --namespace kube-system \ --selector k8s-app=kube-dns } foreach_k3s_version \ factory_reset \ start_kubernetes \ wait_for_kubelet \ wait_for_dns ================================================ FILE: bats/tests/k8s/helm-install-rancher.bats ================================================ # Test case 11 & 12 # bats file_tags=opensuse load '../helpers/load' RD_FILE_RAMDISK_SIZE=12 # We need more disk to run the Rancher image. local_setup() { needs_port 443 } # Check that the rancher-latest/rancher helm chart at the given version is # supported on the current Kubernetes version (as determined by # $RD_KUBERNETES_VERSION) is_rancher_chart_compatible() { local chart_version=$1 run helm show chart rancher-latest/rancher --version "$chart_version" assert_success run awk '/^kubeVersion:/ { $1 = ""; print }' <<<"$output" assert_success # We only support kubeVersion of form "< x.y.z" assert_output --regexp '^[[:space:]]*<[[:space:]]*[^[:space:]]+$' run awk '{ print $2 }' <<<"$output" assert_success local unsupported_version=$output semver_gt "$unsupported_version" "$RD_KUBERNETES_VERSION" } # Set (and save) $rancher_chart_version to $RD_RANCHER_IMAGE_TAG if it is set # (and compatible), or otherwise the oldest chart version that supports # $RD_KUBERNETES_VERSION. # If no compatible chart version could be found, calls mark_k3s_version_skipped # and fails the test. determine_chart_version() { local rancher_chart_version if [[ -n $RD_RANCHER_IMAGE_TAG ]]; then # If a version is given, check that it's compatible. rancher_chart_version=${RD_RANCHER_IMAGE_TAG#v} if ! is_rancher_chart_compatible "$rancher_chart_version"; then mark_k3s_version_skipped printf "Rancher %s is not compatible with Kubernetes %s" \ "$rancher_chart_version" "$RD_KUBERNETES_VERSION" | fail return fi save_var rancher_chart_version return fi local default_version default_version=$(rancher_image_tag) default_version=${default_version#v} run --separate-stderr helm search repo --versions rancher-latest/rancher --output json assert_success run jq_output 'map(.version).[]' assert_success run sort --version-sort <<<"$output" assert_success local versions=$output for rancher_chart_version in $versions; do if ! semver_is_valid "$rancher_chart_version"; then continue # Skip invalid / RC versions. fi if semver_lt "$rancher_chart_version" "$default_version"; then continue # Skip any versions older than the default version fi if is_rancher_chart_compatible "$rancher_chart_version"; then # Once we find a compatible version, use it (and don't look at the # rest of the chart versions). trace "$(printf "Selected rancher chart version %s for Kubernetes %s" \ "$rancher_chart_version" "$RD_KUBERNETES_VERSION")" save_var rancher_chart_version return fi done mark_k3s_version_skipped printf "Could not find a version of rancher-latest/rancher compatible with Kubernetes %s\n" \ "$RD_KUBERNETES_VERSION" | fail } deploy_rancher() { # TODO remove `skip_unless_host_ip` once `traefik_hostname` no longer needs it if is_windows; then skip_unless_host_ip fi local rancher_chart_version if ! load_var rancher_chart_version; then fail "Could not restore Rancher chart version" fi helm upgrade \ --install cert-manager oci://quay.io/jetstack/charts/cert-manager \ --namespace cert-manager \ --set installCRDs=true \ --set "extraArgs[0]=--enable-certificate-owner-ref=true" \ --create-namespace local host host=$(traefik_hostname) comment "Installing rancher $rancher_chart_version" helm upgrade \ --install rancher rancher-latest/rancher \ --version "$rancher_chart_version" \ --namespace cattle-system \ --set hostname="$host" \ --wait \ --timeout=10m \ --create-namespace } verify_rancher() { # TODO remove `skip_unless_host_ip` once `traefik_hostname` no longer needs it if is_windows; then skip_unless_host_ip fi local host host=$(traefik_hostname) run try --max 9 --delay 10 curl --insecure --silent --show-error "https://${host}/dashboard/auth/login" assert_success assert_output --partial 'src="/dashboard/' run kubectl get secret --namespace cattle-system bootstrap-secret -o json assert_success assert_output --partial "bootstrapPassword" } uninstall_rancher() { run helm uninstall rancher --namespace cattle-system --wait assert_nothing run helm uninstall cert-manager --namespace cert-manager --wait assert_nothing } @test 'add helm repo' { helm repo add jetstack https://charts.jetstack.io helm repo add rancher-latest https://releases.rancher.com/server-charts/latest helm repo update } foreach_k3s_version \ determine_chart_version \ factory_reset \ start_kubernetes \ wait_for_kubelet \ wait_for_traefik \ deploy_rancher \ verify_rancher \ uninstall_rancher ================================================ FILE: bats/tests/k8s/port-forwarding.bats ================================================ load '../helpers/load' @test 'start k8s' { factory_reset start_kubernetes wait_for_kubelet } @test 'deploy sample app' { kubectl apply --filename - </dev/null; do assert [ "$(date +%s)" -lt "$timeout" ] sleep 1 done # No way there's a race-condition here. # The version was checked and written to the log file before starting k8s, # and we have to wait a few minutes before k8s is ready and we're at the next line. assert_file_contains "$PATH_LOGS/kube.log" "Requested kubernetes version 'moose' is not a supported version. Falling back to" } # on macOS it still hangs without this @test 'shutdown' { if is_macos; then rdctl shutdown fi } ================================================ FILE: bats/tests/k8s/spinkube-npm.bats ================================================ load '../helpers/load' local_setup_file() { echo "$RANDOM" >"${BATS_FILE_TMPDIR}/random" } local_setup() { if using_docker; then skip "this test only works on containerd right now" fi if ! command -v "npm${EXE}" >/dev/null; then skip "this test requires npm${EXE} to be installed and on the PATH" fi needs_port 80 MY_APP=my-app MY_APP_NAME="${MY_APP}-$(cat "${BATS_FILE_TMPDIR}/random")" MY_APP_IMAGE="ttl.sh/${MY_APP_NAME}:15m" } @test 'start k8s with spinkube' { factory_reset start_kubernetes \ --experimental.container-engine.web-assembly.enabled \ --experimental.kubernetes.options.spinkube wait_for_kubelet wait_for_traefik } @test 'create sample application' { cd "$BATS_FILE_TMPDIR" spin new --accept-defaults --template http-js "$MY_APP" cd "$MY_APP" "npm${EXE}" install spin build spin registry push "$MY_APP_IMAGE" } @test 'wait for spinkube operator' { wait_for_kube_deployment_available --namespace spin-operator spin-operator-controller-manager } @test 'deploy app to kubernetes' { spin kube deploy --context rancher-desktop --from "$MY_APP_IMAGE" } # TODO replace ingress with port-forwarding @test 'deploy ingress' { # TODO remove `skip_unless_host_ip` once `traefik_hostname` no longer needs it if is_windows; then skip_unless_host_ip fi local host host=$(traefik_hostname) kubectl apply --filename - < 0)) echo "$rtc" } create_bats_runtimeclass() { provisioning_script </var/lib/rancher/k3s/server/manifests/zzzz-bats.yaml apiVersion: node.k8s.io/v1 kind: RuntimeClass metadata: name: bats handler: bats YAML EOF } @test 'start k8s without wasm support' { factory_reset create_bats_runtimeclass start_kubernetes wait_for_kubelet } @test 'verify no runtimeclasses have been defined' { run try get_runtime_classes assert_success run jq_output --raw-output '.items[0].metadata.name' assert_success assert_output 'bats' } @test 'start k8s with wasm support' { # TODO We should enable the wasm feature on a running app to make sure the # TODO runtime class is defined even after k3s is initially installed. factory_reset create_bats_runtimeclass start_kubernetes --experimental.container-engine.web-assembly.enabled wait_for_kubelet wait_for_traefik } @test 'verify spin runtime class has been defined (and no others)' { run try get_runtime_classes assert_success rtc=$output run jq '.items | length' <<<"$rtc" assert_success assert_output 2 run jq --raw-output '.items[].metadata.name' <<<"$rtc" assert_success assert_line 'bats' assert_line 'spin' } @test 'deploy sample app' { kubectl apply --filename - < local appdata migration is windows-only' ROAMING_HOME="$(wslpath_from_win32_env APPDATA)/rancher-desktop" } @test 'factory reset' { factory_reset # WSL sometimes ends up not seeing deletes from Windows; force it here. rm -rf "$PATH_CONFIG" "$ROAMING_HOME" } @test 'start app, create a setting, and move settings to roaming' { start_container_engine wait_for_container_engine rdctl api -X PUT /settings --body '{ "version": 9, "WSL": {"integrations": { "beaker" : true }}}' rdctl shutdown create_file "$ROAMING_HOME/settings.json" <"$PATH_CONFIG_FILE" rm -f "$PATH_CONFIG_FILE" } @test 'restart app, verify settings has been migrated' { launch_the_application wait_for_container_engine run rdctl api /settings assert_success run jq_output .WSL.integrations.beaker assert_success assert_output true } @test 'verify the settings file exists in both Local/ and Roaming/' { # Migration doesn't delete it from Roaming/ in case the user decides to roll back to an earlier version. test -f "$PATH_CONFIG_FILE" test -f "$ROAMING_HOME/settings.json" } @test 'verify factory-reset deletes all of Roaming/rancher-desktop' { rdctl factory-reset assert_not_exists "$ROAMING_HOME" } ================================================ FILE: bats/tests/preferences/surface-invalid-args.bats ================================================ load '../helpers/load' local_setup() { skip_on_windows } @test 'initial factory reset' { factory_reset } @test 'mac-specific failure for unacceptable start setting' { if ! is_macos; then skip 'need a mac for the --virtual-machine.type setting' elif supports_vz_emulation; then skip 'no error setting virtualMachine.type to "vz" on this platform' fi RD_NO_MODAL_DIALOGS=1 launch_the_application --virtual-machine.type vz try --max 36 --delay 5 assert_file_contains \ "$PATH_LOGS/background.log" \ 'Setting virtualMachine.type to "vz" on Intel requires macOS 13.0 (Ventura) or later.' rdctl shutdown } @test 'report unrecognized options in the log file' { if ! using_dev_mode; then skip 'hard to get unrecognized options past rdctl-start; run this test in dev-mode' fi yarn dev --his-face-rings-a-bell --no-modal-dialogs & try --max 36 --delay 5 assert_file_contains "$PATH_LOGS/settings.log" "Unrecognized command-line argument --his-face-rings-a-bell" rdctl shutdown } ================================================ FILE: bats/tests/preferences/verify-paths.bats ================================================ # Test case 30 load '../helpers/load' # Ensure subshells don't inherit a path that includes ~/.rd/bin export PATH PATH=$(echo "$PATH" | tr ':' '\n' | grep -v /.rd/bin | tr '\n' ':') local_setup() { if is_windows; then skip "test not applicable on Windows" fi } @test 'factory reset' { factory_reset } @test 'start app' { start_container_engine wait_for_container_engine } # Running `bash -l -c` can cause bats to hang, so close the output file descriptor with '3>&-' @test 'bash managed' { if command -v bash >/dev/null; then run bash -l -c "which rdctl" 3>&- assert_success assert_output --partial "$HOME/.rd/bin/rdctl" else skip 'bash not found' fi } @test 'zsh managed' { if command -v zsh >/dev/null; then run zsh -i -c "which rdctl" assert_success assert_output --partial "$HOME/.rd/bin/rdctl" else skip 'zsh not found' fi } @test 'fish managed' { if command -v fish >/dev/null; then run fish -c "which rdctl" assert_success assert_output --partial "$HOME/.rd/bin/rdctl" else skip 'fish not found' fi } # This bashrc test assumes that this test will succeed, but it frees us # from sleeping after changing application.path-management-strategy no_bashrc_path_manager() { ! grep --silent 'MANAGED BY RANCHER DESKTOP START' "$HOME/.bashrc" } @test 'move to manual path-management' { rdctl set --application.path-management-strategy=manual try --max 5 --delay 2 no_bashrc_path_manager } @test 'bash unmanaged' { if command -v bash >/dev/null; then run bash -l -c "which rdctl" 3>&- # Can't assert success or failure because rdctl might be in a directory other than ~/.rd/bin refute_output --partial "$HOME/.rd/bin/rdctl" else skip 'bash not found' fi } @test 'zsh unmanaged' { if command -v zsh >/dev/null; then run zsh -i -c "which rdctl" refute_output --partial "$HOME/.rd/bin/rdctl" else skip 'zsh not found' fi } @test 'fish unmanaged' { if command -v fish >/dev/null; then run fish -c "which rdctl" refute_output --partial "$HOME/.rd/bin/rdctl" else skip 'fish not found' fi } ================================================ FILE: bats/tests/preferences/verify-settings.bats ================================================ load '../helpers/load' local_setup() { if is_windows; then skip "test not applicable on Windows" fi } @test 'initial factory reset' { factory_reset } @test 'start the app' { start_container_engine wait_for_container_engine } proxy_set() { local field=$1 local value=$2 printf -v payload '{ "version": 10, "experimental": { "virtualMachine": { "proxy": { "%s": %s }}}}' "$field" "$value" run rdctl api settings -X PUT --body "$payload" assert_failure assert_output --partial "Changing field \"experimental.virtualMachine.proxy.${field}\" via the API isn't supported" } @test 'complain about windows-specific vm settings' { run rdctl api /settings assert_success run jq_output .experimental.virtualMachine.proxy.enabled assert_success assert_output false proxy_set enabled "true" for field in address password username; do # Need to include the quotes for a string-value proxy_set $field '"smorgasbord"' done proxy_set port -1 proxy_set noproxy '["buffalo"]' } @test 'ignores echoing current vm settings' { run rdctl api /settings assert_success run jq_output .experimental.virtualMachine.proxy assert_success printf -v payload '{ "version": 10, "experimental": { "virtualMachine": { "proxy": %s }}}' "$output" run rdctl api settings -X PUT --body "$payload" assert_success } ================================================ FILE: bats/tests/profile/create-profile-output.bats ================================================ load '../helpers/load' @test 'factory reset' { factory_reset } BOGUS_CONTAINERD_NAMESPACE=change-to-k8s.io @test 'start app' { start_container_engine wait_for_container_engine rdctl set --images.namespace="$BOGUS_CONTAINERD_NAMESPACE" } @test 'complains when no output type is specified' { run rdctl create-profile --from-settings assert_failure assert_output --partial 'an "--output FORMAT" option of either "plist" or "reg" must be specified' } @test 'complains when an invalid output type is specified' { run rdctl create-profile --from-settings --output=cabbage assert_failure assert_output --partial 'received unrecognized "--output FORMAT" option of "cabbage"; "plist" or "reg" must be specified' } @test 'complains when no input source is specified' { for type in reg plist; do run rdctl create-profile --output $type assert_failure assert_output --partial 'no input format specified: must specify exactly one input format of "--input FILE|-", "--body|-b STRING", or "--from-settings"' done } @test 'complains when no --input or --body arg is specified' { for type in reg plist; do for input in input body; do run rdctl create-profile --output "$type" --"$input" assert_failure assert_output --partial $"Error: flag needs an argument: --$input" done done } too_many_input_formats() { run rdctl create-profile "$@" assert_failure assert_output --partial 'too many input formats specified: must specify exactly one input format of "--input FILE|-", "--body|-b STRING", or "--from-settings"' } @test 'complains when multiple input sources are specified' { for type in reg plist; do too_many_input_formats --output $type --input some-file.txt -b moose too_many_input_formats --output $type --input some-file.txt --from-settings too_many_input_formats --output $type --input some-file.txt -b moose --from-settings too_many_input_formats --output $type -b moose --from-settings done } @test "complains when input file doesn't exist" { run rdctl create-profile --output reg --input /no/such/file/here assert_failure assert_output --partial 'Error: open /no/such/file/here:' } @test 'report invalid parameters for plist' { run rdctl create-profile --output=plist --from-settings --hive=fish assert_failure assert_output --partial $"registry hive and type can't be specified with \"plist\"" run rdctl create-profile --output plist --from-settings --type=writer assert_failure assert_output --partial $"registry hive and type can't be specified with \"plist\"" } @test 'report unrecognized output-options' { run rdctl create-profile --output=pickle assert_failure assert_output --partial 'received unrecognized "--output FORMAT" option of "pickle"; "plist" or "reg" must be specified' } @test 'report unrecognized registry type sub-option' { run rdctl create-profile --output=reg --hive=hklm --type=ruff --from-settings assert_failure assert_output --partial 'invalid registry type of "ruff" specified' } @test 'report unrecognized registry hive sub-option' { run rdctl create-profile --output=reg --hive=stuff --type=locked --from-settings assert_failure assert_output --partial 'invalid registry hive of "stuff" specified' } @test 'report unrecognized registry hive and type sub-options reports only the bad hive' { run rdctl create-profile --output=reg --hive=shelves --type=cows --from-settings assert_failure assert_output --partial 'invalid registry hive of "shelves" specified' } # Happy tests follow # Sample input-generating functions json_maps_and_lists() { cat <<'EOF' { "kubernetes": { "enabled": false }, "containerEngine": { "allowedImages": {"patterns": ["abc", "ghi", "def"] } }, "WSL": { "integrations": { "second": false, "first": true } }, "application": { "extensions": { "allowed": { "enabled": true, "list": [] }, "installed": { } } } } EOF } export -f json_maps_and_lists simple_json_data() { echo '{ "kubernetes": {"version": "moose-head" }}' } export -f simple_json_data # Verify that fields in this structure appear alphabetically in reg output, # and in the same order as the settings struct in plutil output. json_with_special_chars() { cat <<'EOF' { "containerEngine": { "name": "small-less-<-than" }, "application": { "extensions": { "allowed": { "enabled": false, "list": ["less-than:<", "greater:>", "and:&", "d-quote:\"", "emoji:😀"] }, "installed": { "key-with-less-than: <": true, "key-with-ampersand: &": true, "key-with-greater-than: >": true, "key-with-emoji: 🐤": false } } } } EOF } export -f json_with_special_chars assert_full_settings_registry_output() { local hive=$1 local type=$2 assert_success assert_output --partial "Windows Registry Editor Version 5.00" assert_output --partial "[$hive\\SOFTWARE\\Policies\\Rancher Desktop\\$type\\application]" assert_output --partial '"debug"=dword:1' assert_output --partial "[$hive\\SOFTWARE\\Policies\\Rancher Desktop\\$type\\images]" assert_output --partial '"namespace"="'${BOGUS_CONTAINERD_NAMESPACE}'"' } assert_full_settings_plist_output() { assert_success assert_output --partial '' assert_output --partial '' # this next line makes sense only after namespace assert_output --partial "${BOGUS_CONTAINERD_NAMESPACE}" } @test 'generates registry output for hklm/defaults' { run rdctl create-profile --output reg --from-settings assert_full_settings_registry_output HKEY_LOCAL_MACHINE defaults run rdctl create-profile --output reg --hive=hklm --from-settings assert_full_settings_registry_output HKEY_LOCAL_MACHINE defaults run rdctl create-profile --output reg --hive=HKLM --type=Defaults --from-settings assert_full_settings_registry_output HKEY_LOCAL_MACHINE defaults run rdctl create-profile --output reg --type=DEFAULTS --from-settings assert_full_settings_registry_output HKEY_LOCAL_MACHINE defaults } @test 'generates registry output for hklm/locked' { run rdctl create-profile --output reg --hive=Hklm --type=Locked --from-settings assert_full_settings_registry_output HKEY_LOCAL_MACHINE locked run rdctl create-profile --output reg --type=LOCKED --from-settings assert_full_settings_registry_output HKEY_LOCAL_MACHINE locked } @test 'generates registry output for hkcu/defaults' { run rdctl create-profile --output reg --hive=Hkcu --from-settings assert_full_settings_registry_output HKEY_CURRENT_USER defaults run rdctl create-profile --output reg --hive=hkcu --type=Defaults --from-settings assert_full_settings_registry_output HKEY_CURRENT_USER defaults } # This next directive makes no sense. # shellcheck disable=SC2030 @test 'generates registry output for hkcu/locked' { run rdctl create-profile --output reg --hive=HKCU --type=locked --from-settings assert_full_settings_registry_output HKEY_CURRENT_USER locked } assert_check_registry_output() { local bashSideTemp local winSideTemp local testFile local salt local safePolicyName assert_success # We need to formulate /tmp as a directory both sides can see. winSideTemp=$(wslpath -a -m /tmp) # //wsl$/distro/tmp bashSideTemp=$(wslpath -a -u "$winSideTemp") # /tmp testFile=test.reg salt=$$ # Can't write into ...\Policies\ as non-administrator, so populate a different directory. safePolicyName="fakeProfile${salt}" # shellcheck disable=SC2001,SC2031 sed "s/Policies/${safePolicyName}/" <<<"$output" >"${bashSideTemp}/${testFile}" reg.exe import "${winSideTemp}\\${testFile}" reg.exe delete "HKCU\\Software\\${safePolicyName}\\Rancher Desktop" /f /va rm "${bashSideTemp}/${testFile}" } @test 'validate full-setting registry output on Windows' { if ! is_windows; then skip "Test requires the reg utility and only works on Windows" fi run rdctl create-profile --output reg --hive=HKCU --type=defaults --from-settings assert_check_registry_output } @test 'validate special-characters' { if ! is_windows; then skip "Test requires the reg utility and only works on Windows" fi run rdctl create-profile --output reg --hive=HKCU --type=defaults --input - <<<"$(json_with_special_chars)" assert_check_registry_output } @test 'generates registry output from inline json' { run rdctl create-profile --output reg --body '{"application": { "window": { "quitOnClose": true }}}' assert_success SETTINGS_VERSION=$(get_setting .version) printf -v HEX_SETTINGS_VERSION "%x" "$SETTINGS_VERSION" assert_output - < version $SETTINGS_VERSION kubernetes version moose-head EOF } @test 'generates plist output from a command-line argument' { run rdctl create-profile --output plist --body "$(simple_json_data)" assert_moose_head_plist_output } @test 'generates plist output from a file' { run rdctl create-profile --output plist --body "$(simple_json_data)" assert_moose_head_plist_output } @test 'verify plutil is ok with the generated plist output from input file' { if ! is_macos; then skip "Test requires the plist utility and only works on macOS" fi # This input form is ok here because it won't run in WSL/Windows run rdctl create-profile --output plist --input <(simple_json_data) assert_success plutil -s - <<<"$output" } assert_complex_plist_output() { assert_success SETTINGS_VERSION=$(get_setting .version) assert_output - < version $SETTINGS_VERSION application extensions allowed enabled list installed containerEngine allowedImages patterns abc ghi def kubernetes enabled WSL integrations first second EOF } @test 'plist-encodes multi-string values and maps from a file' { run rdctl create-profile --output plist --body "$(json_maps_and_lists)" assert_complex_plist_output } @test 'plist-encodes multi-string values and maps from a json string' { run rdctl create-profile --output plist --body "$(json_maps_and_lists)" assert_complex_plist_output } # Actual output-testing of this input is done in `plist_test.go` -- the purpose of this test is to just # make sure that we're generating compliant data. @test 'verify converted special-char input is escaped and satisfies plutil' { if ! is_macos; then skip "Test requires the plist utility and only works on macOS" fi # This input form is ok here because it won't run in WSL/Windows run rdctl create-profile --output plist --input <(json_with_special_chars) assert_success plutil -s - <<<"$output" } @test 'verify converted special-char output' { SETTINGS_VERSION=$(get_setting .version) run rdctl create-profile --output plist --body "$(json_with_special_chars)" assert_success assert_output - < version $SETTINGS_VERSION application extensions allowed enabled list less-than:< greater:> and:& d-quote:" emoji:😀 installed key-with-ampersand: & key-with-emoji: 🐤 key-with-greater-than: > key-with-less-than: < containerEngine name small-less-<-than END } @test "and shutdown" { if is_macos; then rdctl shutdown fi } ================================================ FILE: bats/tests/profile/deployment.bats ================================================ load '../helpers/load' local_setup() { # Tell start_container_engine to store additional settings in the current # profile and not in settings.json. RD_USE_PROFILE=true RD_USE_IMAGE_ALLOW_LIST=true ALLOWED_EXTENSION_NAME="joycelin79/newman-extension" ALLOWED_EXTENSION_TAG="0.0.7" FORBIDDEN_EXTENSION_TAG="0.0.5" FORBIDDEN_EXTENSION="ignatandrei/blockly-automation" # spellcheck-ignore-line KUBERNETES_RANDOM_VERSION="1.29.5" # profile settings should be the opposite of the default config if using_docker; then DEFAULTS_CONTAINER_ENGINE_NAME=containerd else DEFAULTS_CONTAINER_ENGINE_NAME=moby fi DEFAULTS_START_IN_BACKGROUND=true DEFAULTS_KUBERNETES_VERSION="$RD_KUBERNETES_VERSION" LOCKED_KUBERNETES_VERSION="1.27.3" LOCKED_ALLOWED_IMAGES_ENABLED=true LOCKED_ALLOWED_IMAGES_PATTERNS=("$ALLOWED_EXTENSION_NAME" "$IMAGE_NGINX") LOCKED_EXTENSIONS_ALLOWED_ENABLED=true LOCKED_EXTENSIONS_ALLOWED_LIST=("$ALLOWED_EXTENSION_NAME:$ALLOWED_EXTENSION_TAG") } local_teardown_file() { foreach_profile delete_profile } start_app() { # Store WSL integration and allowed images list in locked profile instead of settings.json PROFILE_TYPE=$PROFILE_LOCKED start_container_engine try --max 40 --delay 5 rdctl api /settings RD_CONTAINER_ENGINE=$(jq_output .containerEngine.name) wait_for_container_engine } verify_profiles() { local PROFILE_TYPE for PROFILE_TYPE in "$PROFILE_LOCKED" "$PROFILE_DEFAULTS"; do run profile_exists "${assert}_success" done } verify_settings() { # settings from defaults profile run get_setting .containerEngine.name "${assert}_output" "$DEFAULTS_CONTAINER_ENGINE_NAME" run get_setting .application.startInBackground "${assert}_output" "$DEFAULTS_START_IN_BACKGROUND" # settings from locked profile run get_setting .containerEngine.allowedImages.enabled "${assert}_output" "$LOCKED_ALLOWED_IMAGES_ENABLED" run get_setting .containerEngine.allowedImages.patterns "${assert}_output" --partial "${LOCKED_ALLOWED_IMAGES_PATTERNS[@]}" run get_setting .application.extensions.allowed.enabled "${assert}_output" "$LOCKED_EXTENSIONS_ALLOWED_ENABLED" run get_setting .application.extensions.allowed.list "${assert}_output" --partial "${LOCKED_EXTENSIONS_ALLOWED_LIST[@]}" run get_setting .kubernetes.version "${assert}_output" "$LOCKED_KUBERNETES_VERSION" "${refute}_output" "$DEFAULTS_KUBERNETES_VERSION" } install_extensions() { # Extension install doesn't work until startup is fully complete. wait_for_backend RD_TIMEOUT=120s run rdctl extension install "$FORBIDDEN_EXTENSION" "${refute}_success" RD_TIMEOUT=120s run rdctl extension install "$ALLOWED_EXTENSION_NAME:$FORBIDDEN_EXTENSION_TAG" "${refute}_success" RD_TIMEOUT=120s run rdctl extension install "${LOCKED_EXTENSIONS_ALLOWED_LIST[0]}" assert_success } @test 'initial factory reset' { factory_reset } @test 'start up with NO profiles' { assert_not_equal "$DEFAULTS_KUBERNETES_VERSION" "$LOCKED_KUBERNETES_VERSION" assert_not_equal "$KUBERNETES_RANDOM_VERSION" "$LOCKED_KUBERNETES_VERSION" RD_USE_PROFILE=false RD_USE_IMAGE_ALLOW_LIST=false start_application } @test 'verify there were NO profiles created' { before verify_profiles } @test 'verify default settings were applied' { before verify_settings } @test 'verify all extensions can be installed' { wait_for_kubelet before install_extensions } @test 'factory reset before creating profiles' { factory_reset } @test 'create profiles' { PROFILE_TYPE=$PROFILE_LOCKED create_profile add_profile_int version 10 PROFILE_TYPE=$PROFILE_DEFAULTS create_profile add_profile_int version 10 add_profile_bool application.startInBackground "$DEFAULTS_START_IN_BACKGROUND" verify_profiles } @test 'create defaults profile' { PROFILE_TYPE=$PROFILE_DEFAULTS add_profile_bool application.startInBackground "$DEFAULTS_START_IN_BACKGROUND" add_profile_string containerEngine.name "$DEFAULTS_CONTAINER_ENGINE_NAME" add_profile_string kubernetes.version "$DEFAULTS_KUBERNETES_VERSION" } @test 'create locked profile' { PROFILE_TYPE="$PROFILE_LOCKED" add_profile_bool containerEngine.allowedImages.enabled "$LOCKED_ALLOWED_IMAGES_ENABLED" add_profile_list containerEngine.allowedImages.patterns "${LOCKED_ALLOWED_IMAGES_PATTERNS[@]}" add_profile_bool application.extensions.allowed.enabled "$LOCKED_EXTENSIONS_ALLOWED_ENABLED" add_profile_list application.extensions.allowed.list "${LOCKED_EXTENSIONS_ALLOWED_LIST[@]}" add_profile_string kubernetes.version "$LOCKED_KUBERNETES_VERSION" } @test 'start app with new profiles' { RD_CONTAINER_ENGINE="" start_app } @test 'verify profile settings were applied' { verify_settings } @test 'install only allowed extensions' { install_extensions } @test 'try to change locked fields via rdctl set' { run rdctl set --container-engine.allowed-images.enabled=false assert_failure assert_output --partial 'field "containerEngine.allowedImages.enabled" is locked' run rdctl set --kubernetes.version="$KUBERNETES_RANDOM_VERSION" assert_failure assert_output --partial 'field "kubernetes.version" is locked' } api_set() { local body version body=$(jq ".version=10" <<<"{$1}") rdctl api /v1/settings -X PUT --body "$body" } @test 'try to change locked fields via API' { run api_set '"containerEngine": {"allowedImages": {"patterns": ["pattern1"]}}' assert_failure assert_output --partial 'field "containerEngine.allowedImages.patterns" is locked' run api_set '"containerEngine": {"allowedImages": {"enabled": false}}' assert_failure assert_output --partial 'field "containerEngine.allowedImages.enabled" is locked' run api_set '"application": {"extensions": {"allowed": {"enabled": false}}}' assert_failure assert_output --partial 'field "application.extensions.allowed.enabled" is locked' run api_set '"application": {"extensions": {"allowed": {"list": ["pattern1"]}}}' assert_failure assert_output --partial 'field "application.extensions.allowed.list" is locked' run api_set '"kubernetes": {"version": "'"$KUBERNETES_RANDOM_VERSION"'"}' assert_failure assert_output --partial 'field "kubernetes.version" is locked' } @test 'ensure locked settings are preserved' { verify_settings } @test 'change defaults profile setting' { run rdctl set --application.start-in-background=false assert_success run rdctl set --application.auto-start=true assert_success run rdctl set --kubernetes.version="$KUBERNETES_RANDOM_VERSION" assert_failure } @test 'verify that the new defaults settings are applied' { DEFAULTS_START_IN_BACKGROUND=false verify_settings run get_setting .application.autoStart assert_output true } @test 'shutdown app' { rdctl shutdown } @test 'restart app' { RD_CONTAINER_ENGINE="" start_app } @test 'verify that default profile is not applied again' { DEFAULTS_START_IN_BACKGROUND=false verify_settings run get_setting .application.autoStart assert_output true } @test 'shutdown Rancher Desktop' { rdctl shutdown } @test 'try to change locked fields via rdctl start and watch it fail' { launch_the_application --container-engine.allowed-images.enabled=false if using_dev_mode; then numTries=36 else numTries=12 fi try --max $numTries --delay 5 assert_file_contains "$PATH_LOGS/background.log" 'field "containerEngine.allowedImages.enabled" is locked' # The app-launch commands are expected to fail. We wait until we see the failure # message in the log file, but at that time the process may still be running. # Make sure that Rancher Desktop has really stopped; otherwise `rdctl start/yarn dev` may not launch a new instance rdctl shutdown launch_the_application --kubernetes.version="1.16.15" try --max $numTries --delay 5 assert_file_contains "$PATH_LOGS/background.log" 'field "kubernetes.version" is locked' # And again verify that the app is no longer running rdctl shutdown } @test 'restart application' { RD_CONTAINER_ENGINE="" start_app } @test 'ensure profile settings are preserved' { DEFAULTS_START_IN_BACKGROUND=false verify_settings } ================================================ FILE: bats/tests/profile/invalid-locked-k8s-version.bats ================================================ load '../helpers/load' local_setup() { RD_USE_PROFILE=true PROFILE_TYPE=$PROFILE_LOCKED } local_teardown_file() { foreach_profile delete_profile } @test 'initial factory reset' { factory_reset } @test 'create profile' { create_profile add_profile_int version 8 add_profile_string kubernetes.version NattyBo } @test 'fails to start app with an invalid locked k8s version' { # Have to set the version field or RD will think we're trying to change a locked field. RD_KUBERNETES_VERSION=NattyBo start_kubernetes # Don't do wait_for_container_engine because RD will shut down in the middle # and the function will take a long time to time out making futile queries. # The app should exit gracefully; after that we can check for contents. try --max 60 --delay 5 assert_file_contains "$PATH_LOGS/background.log" "Child exited" assert_file_contains "$PATH_LOGS/background.log" "Error Starting Rancher Desktop" assert_file_contains "$PATH_LOGS/background.log" "Locked kubernetes version 'NattyBo' isn't a valid version" } @test 'recreate profile with a valid k8s version' { add_profile_string kubernetes.version v1.27.1 } @test 'fails to start app with a specified k8s version != locked k8s version' { factory_reset # Have to set the version field or RD will think we're trying to change a locked field. RD_KUBERNETES_VERSION=v1.27.2 start_kubernetes # The app should exit gracefully; after that we can check for contents. try --max 60 --delay 5 assert_file_contains "$PATH_LOGS/background.log" "Child exited" assert_file_contains "$PATH_LOGS/background.log" "Error Starting Rancher Desktop" assert_file_contains "$PATH_LOGS/background.log" 'field "kubernetes.version" is locked' } ================================================ FILE: bats/tests/profile/wasm.bats ================================================ load '../helpers/load' local_teardown_file() { foreach_profile delete_profile } @test 'create version 10 locked profile' { PROFILE_TYPE=$PROFILE_LOCKED create_profile add_profile_int version 10 } @test 'start application' { factory_reset start_container_engine wait_for_container_engine } @test 'WASM mode should be locked down' { run rdctl set --experimental.container-engine.web-assembly.enabled assert_failure assert_output --partial 'field "experimental.containerEngine.webAssembly.enabled" is locked' } @test 'update locked profile to version 11' { PROFILE_TYPE=$PROFILE_LOCKED add_profile_int version 11 } @test 'restart application with version 11 locked profile' { factory_reset start_container_engine wait_for_backend } @test 'WASM mode is now unlocked' { run rdctl set --experimental.container-engine.web-assembly.enabled assert_success assert_output --partial 'reconfiguring Rancher Desktop to apply changes' } ================================================ FILE: bats/tests/registry/creds.bats ================================================ load '../helpers/load' local_setup() { REGISTRY_PORT="5050" if is_windows && ! using_windows_exe; then # TODO TODO TODO # RD will only modify the Windows version of .docker/config.json; # there is no WSL integration support for it. Therefore this test # always needs to modify the Windows version and not touch the # Linux one. This may change depending on: # https://github.com/rancher-sandbox/rancher-desktop/issues/5523 # TODO TODO TODO USERPROFILE="$(wslpath_from_win32_env USERPROFILE)" fi DOCKER_CONFIG_FILE="$USERPROFILE/.docker/config.json" TEMP=/tmp if is_windows; then # We need to use a directory that exists on the Win32 filesystem # so the ctrctl clients can correctly map the bind mounts. # We can use host_path() on these paths because they will exist # both here and in the rancher-desktop distro. TEMP="$(wslpath_from_win32_env TEMP)" fi AUTH_DIR="$TEMP/auth" CAROOT="$TEMP/caroot" CERTS_DIR="$TEMP/certs" if is_windows && using_docker; then # BUG BUG BUG # docker service on Windows cannot be restarted, so we can't register # a new CA. `localhost` is an insecure registry, not requiring certs. # https://github.com/rancher-sandbox/rancher-desktop/issues/3878 # BUG BUG BUG REGISTRY_HOST="localhost" else # Determine IP address of the VM that is routable inside the VM itself. # Essentially localhost, but needs to be a routable IP that also works # from inside a container. Will be turned into a DNS name using sslip.io. if is_windows; then ipaddr="192.168.143.1" else # Lima uses a fixed hard-coded IP address ipaddr="192.168.5.15" fi REGISTRY_HOST="registry.$ipaddr.sslip.io" fi REGISTRY="$REGISTRY_HOST:$REGISTRY_PORT" } create_registry() { run ctrctl rm -f registry assert_nothing rdshell mkdir -p "$CERTS_DIR" ctrctl run \ --detach \ --name registry \ --restart always \ -p "$REGISTRY_PORT:$REGISTRY_PORT" \ -e "REGISTRY_HTTP_ADDR=0.0.0.0:$REGISTRY_PORT" \ -v "$(host_path "$CERTS_DIR"):/certs" \ -e "REGISTRY_HTTP_TLS_CERTIFICATE=/certs/$REGISTRY_HOST.pem" \ -e "REGISTRY_HTTP_TLS_KEY=/certs/$REGISTRY_HOST-key.pem" \ "$@" \ "$IMAGE_REGISTRY" wait_for_registry } wait_for_registry() { trace "$(ctrctl ps -a)" # registry port is forwarded to host try --max 20 --delay 5 curl -k --silent --show-error "https://localhost:$REGISTRY_PORT/v2/_catalog" } using_insecure_registry() { [ "$REGISTRY_HOST" = "localhost" ] } skip_for_insecure_registry() { if using_insecure_registry; then skip "BUG: docker on Windows can only use insecure registry" fi } @test 'factory reset' { factory_reset rm -f "$DOCKER_CONFIG_FILE" } @test 'start container engine' { start_container_engine wait_for_shell for dir in "$AUTH_DIR" "$CAROOT" "$CERTS_DIR"; do rdshell rm -rf "$dir" done if using_image_allow_list; then update_allowed_patterns true "$IMAGE_REGISTRY" "$REGISTRY" fi } @test 'wait for container engine' { wait_for_container_engine } @test 'verify credential is set correctly' { verify_default_credStore } verify_default_credStore() { local CREDHELPER_NAME CREDHELPER_NAME="$(basename "$CRED_HELPER" .exe | sed s/^docker-credential-//)" run jq --raw-output .credsStore "$DOCKER_CONFIG_FILE" assert_success assert_output "$CREDHELPER_NAME" } @test 'verify allowed-images config' { run ctrctl pull --quiet "$IMAGE_BUSYBOX" if using_image_allow_list; then assert_failure assert_output --regexp "(UNAUTHORIZED|Forbidden)" else assert_success fi } @test 'create server certs for registry' { rdsudo apk add mkcert --force-broken-world --repository https://dl-cdn.alpinelinux.org/alpine/edge/testing rdshell mkdir -p "$CAROOT" "$CERTS_DIR" rdshell sh -c "CAROOT=\"$CAROOT\" TRUST_STORES=none mkcert -install" rdshell sh -c "cd \"$CERTS_DIR\"; CAROOT=\"$CAROOT\" mkcert \"$REGISTRY_HOST\"" } @test 'pull registry image' { ctrctl pull --quiet "$IMAGE_REGISTRY" } @test 'create plain registry' { create_registry } @test 'tag image with registry' { ctrctl tag "$IMAGE_REGISTRY" "$REGISTRY/registry" } @test 'expect push image to registry to fail because CA cert has not been installed' { skip_for_insecure_registry run ctrctl push "$REGISTRY/registry" assert_failure # we don't get cert errors when going through the proxy; they turn into 502's assert_output --regexp "(certificate signed by unknown authority|502 Bad Gateway)" } @test 'install CA cert' { skip_for_insecure_registry rdsudo cp "$CAROOT/rootCA.pem" /usr/local/share/ca-certificates/ rdsudo update-ca-certificates } restart_container_engine() { # BUG BUG BUG # When using containerd, sometimes the container would get wedged on a # restart; however, restarting containerd again seems to fix this. # So we need to keep trying until the registry container is not `created`. # BUG BUG BUG service_control "$CONTAINER_ENGINE_SERVICE" restart service_control --ifstarted rd-openresty restart wait_for_container_engine trace "$(ctrctl ps -a)" if using_containerd; then run ctrctl ps --filter status=created,name=registry --format '{{.Names}}' assert_success refute_output registry fi } @test 'restart container engine to refresh certs' { skip_for_insecure_registry try restart_container_engine wait_for_registry } @test 'expect push image to registry to succeed now' { ctrctl push "$REGISTRY/registry" } @test 'create registry with basic auth' { # note: docker htpasswd **must** use bcrypt algorithm, i.e. `htpasswd -nbB user password` # We intentionally use single-quotes; the '$' characters are literals # shellcheck disable=SC2016 HTPASSWD='user:$2y$05$pd/kWjYSW9x48yaPQgrl.eLn02DdMPyoYPUy/yac601k6w.okKgmG' rdshell mkdir -p "$AUTH_DIR" echo "$HTPASSWD" | rdshell tee "$AUTH_DIR/htpasswd" >/dev/null create_registry \ -v "$(host_path "$AUTH_DIR"):/auth" \ -e REGISTRY_AUTH=htpasswd \ -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \ -e REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm" } @test 'verify that registry requires basic auth' { local curl_options=(--silent --show-error) if using_insecure_registry; then curl_options+=(--insecure) fi local registry_url="https://$REGISTRY/v2/_catalog" run rdshell curl "${curl_options[@]}" "$registry_url" assert_success assert_output --partial '"message":"authentication required"' run rdshell curl "${curl_options[@]}" --user user:password "$registry_url" assert_success assert_output '{"repositories":[]}' } @test 'verify that pushing fails when not logged in' { run bash -c "echo \"$REGISTRY\" | \"$CRED_HELPER\" erase" assert_nothing run ctrctl push "$REGISTRY/registry" assert_failure assert_output --regexp "(401 Unauthorized|no basic auth credentials)" } @test 'verify that pushing succeeds after logging in' { run ctrctl login -u user -p password "$REGISTRY" assert_success assert_output --partial "Login Succeeded" ctrctl push "$REGISTRY/registry" } @test 'verify credentials in host cred store' { run bash -c "echo \"$REGISTRY\" | \"$CRED_HELPER\" get" assert_success assert_output --partial '"Secret":"password"' ctrctl logout "$REGISTRY" run bash -c "echo \"$REGISTRY\" | \"$CRED_HELPER\" get" refute_output --partial '"Secret":"password"' } @test 'verify the docker-desktop credential helper is replaced with the rancher-desktop default' { factory_reset create_file "$DOCKER_CONFIG_FILE" <<<'{ "credsStore": "desktop" }' start_container_engine wait_for_container_engine verify_default_credStore } ================================================ FILE: bats/tests/snapshots/create-use-snapshot.bats ================================================ load '../helpers/load' local_setup() { SNAPSHOT=the-ubiquitous-flounder } @test 'factory reset and delete all the snapshots' { delete_all_snapshots factory_reset } @test 'start up in moby' { RD_CONTAINER_ENGINE=moby start_kubernetes wait_for_container_engine wait_for_kubelet wait_for_backend } @test 'push an nginx pod and verify' { kubectl run nginx --image="$IMAGE_NGINX" --port=8080 try --max 48 --delay 5 running_nginx } @test 'shutdown, make a snapshot, and run factory-reset' { rdctl shutdown snapshot_description="first snapshot" rdctl snapshot create "$SNAPSHOT" --description "$snapshot_description" run rdctl snapshot list assert_success assert_output --partial "$SNAPSHOT" assert_output --partial "$snapshot_description" rdctl factory-reset } @test 'startup, verify using new settings' { RD_CONTAINER_ENGINE=containerd start_kubernetes wait_for_container_engine wait_for_kubelet run rdctl api /settings assert_success run jq_output .containerEngine.name assert_success assert_output --partial containerd run kubectl get pods -A assert_success refute_output --regexp 'default.*nginx.*Running' } # This should be one long test because if `snapshot restore` fails there's no point starting up @test 'shutdown, restore, restart and verify snapshot state' { rdctl shutdown run rdctl snapshot restore "$SNAPSHOT" assert_success refute_output --partial fail launch_the_application # Keep this variable in sync with the current setting so the wait_for commands work RD_CONTAINER_ENGINE=moby wait_for_container_engine wait_for_kubelet run rdctl api /settings assert_success run jq_output .containerEngine.name assert_success assert_output moby try --max 48 --delay 5 running_nginx } @test 'verify identification errors' { for action in restore delete; do run rdctl snapshot "$action" 'the-nomadic-pond' assert_failure assert_output --partial "Error: failed to $action snapshot \"the-nomadic-pond\": can't find snapshot \"the-nomadic-pond\"" run rdctl snapshot "$action" 'the-nomadic-pond' --json assert_failure run jq_output '.error' assert_success assert_output "failed to $action snapshot \"the-nomadic-pond\": can't find snapshot \"the-nomadic-pond\"" done } @test "attempt to create a snapshot with an existing name is flagged and doesn't do factory-reset" { # Shutdown RD for faster snapshot creation # Also verify that the failed creation doesn't trigger a factory-reset and remove settings.json rdctl shutdown assert_exists "$PATH_CONFIG_FILE" run rdctl snapshot create "$SNAPSHOT" --json assert_failure run jq_output '.error' assert_success assert_output "name \"$SNAPSHOT\" already exists" assert_exists "$PATH_CONFIG_FILE" } @test 'rejects attempts to create a snapshot with different description sources' { run rdctl snapshot create --description abc --description-from my-sad-file my-happy-snapshot-2 assert_failure assert_output --partial "Error: can't specify more than one option from \"--description\" and \"--description-from\"" } @test 'can create a snapshot where proposed name is a current ID' { run ls -1 "$PATH_SNAPSHOTS" assert_success refute_output "" snapshot_id="${lines[0]}" test -n "$snapshot_id" snapshot_description="second snapshot made with the --description option with \\ and \" and '." rdctl snapshot create "$snapshot_id" --description "$snapshot_description" run rdctl snapshot list --json assert_success run jq_output "select(.name == \"$snapshot_id\").description" assert_success assert_output "$snapshot_description" # And we can delete that snapshot run rdctl snapshot delete "$snapshot_id" --json assert_success assert_output "" } @test 'very long descriptions are truncated in the table view' { snapshot_name=armadillo_farm description_part="very long description names are truncated in the table view" long_description="$description_part, repeat: $description_part" rdctl snapshot create "$snapshot_name" --description "$long_description" run rdctl snapshot list --json assert_success run jq_output "select(.name == \"$snapshot_name\").description" assert_success assert_output "$long_description" run rdctl snapshot list assert_success run grep "$snapshot_name" <<<"$output" assert_success # Shouldn't have the whole description, but part of it refute_output --partial "$long_description" assert_output --partial "$description_part" } @test 'table view truncates descriptions at an internal newline' { snapshot_name=retinal_asparagus newline=$'\n' part1="there's a new" description="${part1}${newline}line somewhere in this description" rdctl snapshot create "$snapshot_name" --description "$description" run rdctl snapshot list --json assert_success run jq_output "select(.name == \"$snapshot_name\").description" assert_success assert_output "$description" run rdctl snapshot list assert_success run grep "$snapshot_name" <<<"$output" assert_success # Shouldn't have the whole description, but part of it refute_output --partial "$description" assert_output --partial "${part1}…" } @test "factory-reset doesn't delete a non-empty snapshots directory" { rdctl factory-reset assert_exists "$PATH_SNAPSHOTS" } @test 'factory-reset does delete an empty snapshots directory' { delete_all_snapshots rdctl factory-reset assert_not_exists "$PATH_SNAPSHOTS" } running_nginx() { run kubectl get pods -A assert_success assert_output --regexp 'default.*nginx.*Running' } ================================================ FILE: bats/tests/snapshots/restore-snapshot-after-factory-reset.bats ================================================ load '../helpers/load' local_setup() { SNAPSHOT=some-test-snapshot-name } @test 'factory reset and delete all the snapshots' { delete_all_snapshots factory_reset } @test 'start up using containerd' { # TODO TODO TODO # Using the container engine name to check if a snapshot has been # restored is one of the worst possible choices, as the engine choice # is built into so much logic in the helper functions. # Maybe pull an image and verify the image is still there after the # restore. # TODO TODO TODO RD_CONTAINER_ENGINE=containerd start_kubernetes wait_for_container_engine wait_for_kubelet wait_for_backend } @test 'shut down and make a snapshot' { rdctl shutdown rdctl snapshot create "$SNAPSHOT" run rdctl snapshot list assert_success assert_output --partial "$SNAPSHOT" } @test 'do a factory reset' { rdctl factory-reset } @test 'restore the snapshot without starting up first' { run rdctl snapshot restore "$SNAPSHOT" assert_success } @test 'start back up' { # don't provide a --container-engine.name argument to `rdctl start` RD_CONTAINER_ENGINE="" # don't create settings.json file RD_USE_PROFILE=true start_kubernetes unset RD_USE_PROFILE # make sure we are not waiting for the docker context to be created RD_CONTAINER_ENGINE="containerd" wait_for_container_engine wait_for_kubelet wait_for_backend } @test 'verify that we are running containerd' { run rdctl api /settings assert_success run jq_output .containerEngine.name assert_success assert_output --partial containerd } @test 'delete the snapshot and verify there are no others' { rdctl snapshot delete "$SNAPSHOT" run rdctl snapshot list --json assert_success assert_output '' } ================================================ FILE: bats/tests/snapshots/test-snapshot-list.bats ================================================ load '../helpers/load' local_setup() { skip_on_windows "snapshots test not applicable on Windows" NON_ALNUM_SNAPSHOT_NAME='@#$%' MULTI_WORD_SNAPSHOT_NAME='=with '\''single'\'' and "double" quotes, /slashes/, and \backslashes\.' EMOJI_SNAPSHOT_NAME="emoji's 😍 are cool" NON_ALNUM_DESCRIPTION='description for non-alnum-snapshot-name' MULTI_WORD_DESCRIPTION='description for multi-word-snapshot-name' EMOJI_DESCRIPTION='description for emoji-snapshot-name' TEMP=$BATS_FILE_TMPDIR if is_windows; then TEMP="$(wslpath_from_win32_env TEMP)" fi } @test 'factory reset and delete all the snapshots' { delete_all_snapshots factory_reset } # This test ensures that we have something to take a snapshot of, because appHome might not exist. @test 'start up' { start_kubernetes wait_for_container_engine wait_for_kubelet } @test 'verify empty snapshot-list output' { run rdctl snapshot list --json assert_success assert_output '' run rdctl snapshot list assert_success assert_output 'No snapshots present.' } @test 'create three snapshots with RD turned off, spaced every 5 seconds' { # It's much faster to create snapshots when RD isn't running. rdctl shutdown # Sleep 5 seconds after creating each snapshot so later we can verify # that the differences in each snapshot's creation time makes sense. rdctl snapshot create --description-from - "$NON_ALNUM_SNAPSHOT_NAME" <<<"$NON_ALNUM_DESCRIPTION" sleep 5 rdctl snapshot create --description-from - "$MULTI_WORD_SNAPSHOT_NAME" <<<"$MULTI_WORD_DESCRIPTION" sleep 5 DESC_FILE="$TEMP/emoji-snapshot-description.txt" echo "$EMOJI_DESCRIPTION" >"$DESC_FILE" rdctl snapshot create --description-from "$DESC_FILE" "$EMOJI_SNAPSHOT_NAME" } created() { local name name=$(json_string "$1") jq_output "select(.name == $name).created" } @test 'verify snapshot-list output with snapshots' { run rdctl snapshot list --json assert_success DATE1=$(created "$MULTI_WORD_SNAPSHOT_NAME") DATE2=$(created "$EMOJI_SNAPSHOT_NAME") if is_macos; then TIME1=$(/bin/date -jf "%Y-%m-%dT%H:%M:%S" "$DATE1" +%s 2>/dev/null) TIME2=$(/bin/date -jf "%Y-%m-%dT%H:%M:%S" "$DATE2" +%s 2>/dev/null) elif is_linux; then TIME1=$(date --date="$DATE1" +%s) TIME2=$(date --date="$DATE2" +%s) fi # This is all we can assert, because we don't have an upper bound for the time # between the two `snapshot create's`, and we don't have info on fractions of a second, # so a difference of 4.9999 could show up as 4 ((TIME2 - TIME1 > 4)) run rdctl snapshot list assert_success assert_output --partial "$NON_ALNUM_SNAPSHOT_NAME" assert_output --partial "$MULTI_WORD_SNAPSHOT_NAME" assert_output --partial "$EMOJI_SNAPSHOT_NAME" assert_output --partial "$NON_ALNUM_DESCRIPTION" assert_output --partial "$MULTI_WORD_DESCRIPTION" assert_output --partial "$EMOJI_DESCRIPTION" } @test 'verify k8s is off' { start_container_engine wait_for_container_engine wait_for_backend run rdctl api /v1/settings assert_success run jq_output .kubernetes.enabled assert_success assert_output "false" } @test 'create a snapshot with k8s off' { # This tests that wait_for_backend accepts the DISABLED state as a final state. rdctl snapshot create anime-walnut-festival wait_for_container_engine wait_for_backend } @test 'and verify the new snapshot is listed' { run rdctl snapshot list assert_success assert_output --partial anime-walnut-festival } @test 'and clean up' { delete_all_snapshots run rdctl snapshot list assert_success assert_output 'No snapshots present.' } ================================================ FILE: bats/tests/snapshots/test_rdctl_snapshot.bats ================================================ load '../helpers/load' # TODO: Uncomment this test when snapshots go unhidden. #@test 'snapshot shows up in general help' { # run rdctl --help # assert_success # assert_output -partial snapshot #} @test 'complain about missing argument' { # These test the rdctl cmd layer, can't be easily unit-tested for arg in create restore delete; do run rdctl snapshot "$arg" assert_failure done } ================================================ FILE: bats/tests/utils/rdctl.bats ================================================ load '../helpers/load' # Verify various operations of `rdctl` @test 'factory reset' { factory_reset } @test 'start Rancher Desktop' { start_container_engine wait_for_container_engine } @test 'rdctl info' { run --separate-stderr rdctl info assert_success assert_output --partial 'Version:' } @test 'rdctl info --output=json' { run --separate-stderr rdctl info --output=json assert_success json=$output run jq_output .version assert_success assert_output --regexp '^v1\.' output=$json run jq_output '.["ip-address"]' assert_success assert_output --regexp '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' } @test 'rdctl info --field version' { run rdctl info --field version assert_success assert_output --regexp '^v1\.' } @test 'rdctl info --field ip-address' { run rdctl info --field ip-address assert_success if is_windows; then # On Windows, the IP address should be constant. assert_output 192.168.127.2 elif is_linux; then assert_output 192.168.5.15 # qemu SLIRP elif is_macos; then address=$output if is_true "$(get_setting '.application.adminAccess')"; then # This is provided by the user's DHCP server output=$address assert_output --regexp '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' elif [[ $(get_setting .virtualMachine.type) == vz ]]; then # macOS Virtualization.Framework NAT; should be a local address output=$address assert_output --regexp '^192\.168\.' # but the address should not be from the regular SLIRP range output=$address refute_output --regexp '^192\.168\.5\.' else output=$address assert_output 192.168.5.15 # qemu SLIRP fi else fail 'Unknown OS' fi } ================================================ FILE: bats/tests/utils/spin.bats ================================================ load '../helpers/load' # Verify that enabling Wasm support will install spin plugins and templates local_setup() { SPIN_DATA_DIR="${PATH_APP_HOME}/spin" } cmd_exe() { "${SYSTEMROOT}/system32/cmd.exe" /c "$@" } dir_exists() { if using_windows_exe; then run --separate-stderr cmd_exe if exist "$(host_path "$1")" echo True # Output may have trailing \r [[ $output =~ ^True ]] else [[ -d $1 ]] fi } @test 'delete spin plugins and templates' { if using_windows_exe; then run cmd_exe rmdir /s /q "$(host_path "${SPIN_DATA_DIR:?}")" assert_nothing else rm -rf "${SPIN_DATA_DIR:?}" fi } @test 'confirm the spin directory is gone' { run dir_exists "$SPIN_DATA_DIR" assert_failure } @test 'start container engine with wasm support enabled' { factory_reset start_container_engine --experimental.container-engine.web-assembly.enabled wait_for_container_engine } @test 'plugins are installed' { run dir_exists "${SPIN_DATA_DIR}/plugins/kube" assert_success } @test 'templates are installed' { if using_windows_exe; then run --separate-stderr cmd_exe dir /b "$(host_path "${SPIN_DATA_DIR}/templates")" assert_success else run ls -1 "${SPIN_DATA_DIR}/templates" assert_success fi assert_line --regexp "^http-go_" # from spin assert_line --regexp "^http-js_" # from spin-js-sdk assert_line --regexp "^http-py_" # from spin-python-sdk } ================================================ FILE: build/electron-publisher-custom.js ================================================ const childProcess = require('child_process'); const fs = require('fs'); const path = require('path'); const url = require('url'); const util = require('util'); const electronPublish = require('electron-publish'); class LonghornPublisher extends electronPublish.Publisher { providerName = 'longhorn'; toString() { return ''; } upload() { // We're not doing any uploading here. return Promise.resolve(); } /** * checkAndResolveOptions is used to resolve publisher configs, which is then * stored in the `app-update.yml` config file shipped with the application. */ static async checkAndResolveOptions(options) { // Try to auto-fill the GitHub repository info. if (!options.owner || !options.repo) { // Try to get the repository info from package.json let repository; const packagePath = path.join(path.dirname(module.path), 'package.json'); const packageData = JSON.parse(await fs.promises.readFile(packagePath, { encoding: 'utf8' })); if (packageData.repository.url) { repository = new url.URL(packageData.repository.url); } else { // Try to get the repository info from git config const execFile = util.promisify(childProcess.execFile); const { stdout } = await execFile('git', ['config', 'remote.origin.url']); repository = new url.URL(stdout.trim()); } if (repository.hostname === 'github.com') { const [, owner, repo] = repository.pathname.replace(/\.git$/, '').split('/'); options.owner = options.owner || owner; options.repo = options.repo || repo; } } } } module.exports = LonghornPublisher; ================================================ FILE: build/license.rtf ================================================ {\rtf1 {\fonttbl{\f0\fmodern\fcharset0}} \f0\fs18 {\qc Apache License\line Version 2.0, January 2004\line http://www.apache.org/licenses/ \par } \par\ql TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\par\par 1. Definitions.\par\par {\li200 "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.\par\par "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.\par\par "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.\par\par "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.\par\par "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.\par\par "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.\par\par "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).\par\par "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.\par\par "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."\par\par "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.\par\par } 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.\par\par 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.\par\par 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: \par\par {\fi-100\li200 a. You must give any other recipients of the Work or Derivative Works a copy of this License; and\par\par b. You must cause any modified files to carry prominent notices stating that You changed the files; and\par\par c. You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and\par\par d. If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.\par\par You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.\par\par } 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.\par\par 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.\par\par 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.\par\par 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.\par\par 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.\par\par \par END OF TERMS AND CONDITIONS\par \par How to apply the Apache License to your work\par\par Include a copy of the Apache License, typically in a file called LICENSE, in your work, and consider also including a NOTICE file that references the License. \par To apply the Apache License to specific files in your work, attach the following boilerplate declaration, replacing the fields enclosed by brackets "[]" with your own identifying information. (Don't include the brackets!) Enclose the text in the appropriate comment syntax for the file format. We also recommend that you include a file or class name and description of purpose on the same "printed page" as the copyright notice for easier identification within third-party archives. \par \pard \par Copyright [yyyy] [name of copyright owner] \par \par Licensed under the Apache License, Version 2.0 (the "License"); \par you may not use this file except in compliance with the License. \par You may obtain a copy of the License at \par \par http://www.apache.org/licenses/LICENSE-2.0 \par \par Unless required by applicable law or agreed to in writing, software \par distributed under the License is distributed on an "AS IS" BASIS, \par WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. \par See the License for the specific language governing permissions and \par limitations under the License.} ================================================ FILE: build/signing-config-mac.yaml ================================================ # This file describes the code signing configuration for macOS. # List of entitlements. entitlements: # This contains the default entitlements, for files not otherwise listed. default: - com.apple.security.inherit # Entitlement overrides. This is a list of overrides, each with a "paths" # key describing which paths to override, and an "entitlements" key for the # overriding entitlements. overrides: - paths: - '' # This is the main application entitlements: - com.apple.security.cs.allow-jit - paths: - Contents/Resources/resources/darwin/lima/bin/limactl entitlements: - com.apple.security.virtualization - paths: - Contents/Resources/resources/darwin/lima/bin/qemu-system-aarch64 - Contents/Resources/resources/darwin/lima/bin/qemu-system-x86_64 entitlements: - com.apple.security.cs.allow-jit - com.apple.security.hypervisor - paths: - Contents/Resources/resources/darwin/internal/spin entitlements: - com.apple.security.cs.allow-unsigned-executable-memory - paths: - Contents/Frameworks/Rancher Desktop Helper (GPU).app - Contents/Frameworks/Rancher Desktop Helper (Renderer).app entitlements: - com.apple.security.cs.allow-jit - paths: - Contents/Frameworks/Rancher Desktop Helper (Plugin).app entitlements: - com.apple.security.cs.allow-unsigned-executable-memory - com.apple.security.cs.disable-library-validation # List of launch constraints. # This is similar to entitlements, but has no default: it's just a list of # paths, plus matching "self", "parent", and "responsible" constraints. constraints: - paths: - Contents/Resources/resources/darwin/lima/bin/limactl self: team-identifier: '${AC_TEAMID}' # A list of files/directories to remove before signing. remove: - Contents/build - Contents/electron-builder.yml ================================================ FILE: build/signing-config-win.yaml ================================================ # This file describes the code signing configuration for Windows. # The key is a directory name, relative to the unpacked zip file. # The value is an array of files in that directory to sign, or an explicit # negation (prefixed with "!"). Any files not listed is an error. .: - Rancher Desktop.exe - wix-custom-action.dll - '!dxcompiler.dll' # spellcheck-ignore-line - '!ffmpeg.dll' - '!libEGL.dll' # spellcheck-ignore-line - '!libGLESv2.dll' # spellcheck-ignore-line - '!vk_swiftshader.dll' # spellcheck-ignore-line - '!vulkan-1.dll' # spellcheck-ignore-line resources/resources/win32/bin: - docker.exe - docker-credential-none.exe - nerdctl.exe - rdctl.exe - spin.exe - '!docker-credential-ecr-login.exe' - '!docker-credential-wincred.exe' - '!helm.exe' - '!kubectl.exe' - '!kuberlr.exe' resources/resources/win32/docker-cli-plugins: - '!docker-buildx.exe' - '!docker-compose.exe' resources/resources/win32/internal: - host-switch.exe - steve.exe - wsl-helper.exe - '!spin.exe' ================================================ FILE: build/wix/dialogs.wxs ================================================ !(loc.Error_WSLNotInstalled) WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed 1 1 1 1 1 1 Installed AND NOT RESUME AND NOT Preselected AND NOT PATCH ================================================ FILE: build/wix/main.wxs ================================================ = 603 ]]> NOT WSLINSTALLED AND NOT Installed (NOT WSLINSTALLED OR NOT MSIINSTALLPERUSER) AND NOT Installed NOT WSLINSTALLED NOT WSLINSTALLED AND NOT Installed NOT WSLINSTALLED NOT WSLINSTALLED AND NOT Installed WSLINSTALLED AND WSLKERNELOUTDATED NSISUNINSTALLCOMMAND AND NOT Installed MSIINSTALLPERUSER = 1 1]]> 1]]> 1]]> RDRUNAFTERINSTALL {{ &fileList }} ================================================ FILE: build/wix/scope.wxs ================================================ 1 1 1 ================================================ FILE: build/wix/string-overrides.wxl ================================================ [ProductName] will be installed in a per-machine folder and be available for all users. This enables listening on non-loopback ports and other features that require additional privileges. You must have local Administrator privileges. [ProductName] will be installed in a per-user folder and be available just for your user account. You do not need local Administrator privileges. This will disable support for listening on non-loopback ports, and requires WSL2 to already be installed on your machine. [ProductName] cannot be installed per-user, as Windows Subsystem for Linux 2 was not found. Continuing to install [ProductName] will also install WSL2. [ProductName] requires Windows Subsystem for Linux 2 (WSL2) to be installed as a prerequisite. Please follow the instructions at https://aka.ms/wslinstall ================================================ FILE: build/wix/verify.wxs ================================================ 1 ================================================ FILE: build/wix/welcome.wxs ================================================ "1"]]> RDLicenseAccepted = "1" 1 NOT WSLINSTALLED 1 NOT Installed AND NOT WIX_UPGRADE_DETECTED AND NOT REMOVE ================================================ FILE: dev-app-update.yml ================================================ owner: rancher-sandbox repo: rancher-desktop updaterCacheDirName: rancher-desktop provider: custom upgradeServer: https://desktop.version.rancher.io/v1/checkupgrade vPrefixedTagName: true ================================================ FILE: docs/development/README.md ================================================ # Developer Documentation Note that the below table of contents may be out of date (we may forget to update it). If you don't find what you need, ask around! [Information About Factory Reset](factory-reset.md) [Feature Tracker](features.md) [Tips for Working with OBS](obs.md) [Linux Release Process](linux-release-process.md) [Release Checklist](release-checklist.md) [Signing Rancher Desktop Releases](signing.md) [Generating Screenshots for User Documentation](../../screenshots/README.md) [Information on how to setup and run BATS tests](../../bats/README.md) ================================================ FILE: docs/development/env.md ================================================ # Internal Rancher Desktop environment variables These variables are used for build and development purposes; they are not meant to be set by users. They do not form an API and may be changed or removed at any time without prior notice. ## RD_DEBUG_ENABLED=anything Forces debug logging to always be enabled. Useful to debug first-run issues when there is no `settings.yaml` yet to set debug mode. ## RD_FORCE_UPDATES_ENABLED=anything When set, it will force auto-update to be enabled even in `yarn dev` mode. Updates will be checked and downloaded, but **not** installed. ## RD_MOCK_MACOS_VERSION=semver Used for testing compatibility of the app with the OS version, for upgrade responder tests, and for enabling/disabling certain parts of the preferences (related to VZ emulation mode). ## RD_UPGRADE_RESPONDER_URL=http://localhost:8314/v1/checkupgrade Set an alternate upgrade responder endpoint for testing. ================================================ FILE: docs/development/factory-reset.md ================================================ When `rdctl reset --factory` is launched from the UI, it writes its stdout into `TMP/rdctl-stdout.txt` where on linux `TMP` is usually `/tmp`, on macOS it's given by `$TMPDIR` and on Windows by `%TEMP%`(command shell) or `$env:TEMP`(powershell). This is most useful during development. When the UI runs in debug mode, it spawns `rdctl reset --factory` with the `--verbose` option. We can't write the output into the `logs` directory as `reset --factory` deletes it. ================================================ FILE: docs/development/features.md ================================================ # Rancher Desktop Features This document lists the high-level Rancher Desktop features and their current status. | Symbol | Description | | ------------- | ---------------- | | :heavy_check_mark: | released | | :calendar: | targeted for the [next] or the [later] milestone release | | :sun_with_face:| not planned yet, but considering for a future release | Note: - Items under the [next] milestone are targeted for the upcoming monthly release, which usually happens on the 4th Wednesday of the month. - Items under the [later] milestone and any spillover items from the [next] milestone are targeted for the release after. - Items under the [next] and [later] milestones might change based on user feedback, technical challenges, etc. [next]: https://github.com/rancher-sandbox/rancher-desktop/projects/1?card_filter_query=milestone%3Anext [later]: https://github.com/rancher-sandbox/rancher-desktop/projects/1?card_filter_query=milestone%3Alater ### OS & Platform Support :heavy_check_mark: Win 10/11 :heavy_check_mark: Mac (Intel) :heavy_check_mark: Mac M1 (apple silicon) :heavy_check_mark: Linux :sun_with_face: Linux AArch64 :sun_with_face: Windows on AArch64 :sun_with_face: Windows Containers ### Container Engines :heavy_check_mark: Multiple CR support (containerd, dockerd) ### Docker :heavy_check_mark: CLI :heavy_check_mark: Swarm :heavy_check_mark: Compose :heavy_check_mark: Docker-only ### Kubernetes :heavy_check_mark: K3s bundled :heavy_check_mark: Multiple versions support ### Bundled Tooling :heavy_check_mark: Helm :sun_with_face: Kubectx :sun_with_face: [kwctl] [kwctl]: https://github.com/kubewarden/kwctl ### Image Management :heavy_check_mark: Build, Push, Pull & Scan images :calendar: Registry Configuration :sun_with_face: Registry Access Control ### Networking :heavy_check_mark: Simple VPN :calendar: Restricted VPN (Ex: Cisco AnyConnect) ### Host Access :sun_with_face: GPU :sun_with_face: USB ### Performance & System Resources :heavy_check_mark: System resource allocation :sun_with_face: Pause app to save power ### Security :heavy_check_mark: Signed builds :sun_with_face: SBOM generation for images :sun_with_face: Image Signing :sun_with_face: Attain SLSA Level ### Troubleshooting :heavy_check_mark: View logs :heavy_check_mark: Partial Reset :heavy_check_mark: Factory Reset ### GUI/Installation :heavy_check_mark: View Containers :heavy_check_mark: View Images :heavy_check_mark: Port forwarding :heavy_check_mark: Auto updates :heavy_check_mark: Cluster exploration - Rancher Dashboard (Preview) :sun_with_face: Container Exploration :sun_with_face: Configuration settings :sun_with_face: Start/Stop/Pause Containers :sun_with_face: Silent (No-GUI) Install :sun_with_face: CLI/Headless mode :calendar: Offline (air gap) mode :heavy_check_mark: Rancher Desktop CLI aka rdctl (Preview) ### IDE Compatibility :heavy_check_mark: VS Code extension (With dockerd(moby)) :sun_with_face: Visual Studio IDE (Needs Validation) :sun_with_face: Eclipse (Needs Validation) ### Integration with Other Rancher Projects :heavy_check_mark: k3s :calendar: Rancher Dashboard :sun_with_face: Epinio :sun_with_face: NeuVector :sun_with_face: Marketplace :sun_with_face: Kubewarden ### Development :heavy_check_mark: Open source :heavy_check_mark: Public roadmap ================================================ FILE: docs/development/linux-release-process.md ================================================ # Linux Release Process **Note**: please read the [OBS Tips Documentation](obs.md) before this document. It includes information that is important to be familiar with when working with OBS. ## When do I need to modify OBS? OBS is set up so that you only need to act when you are releasing a new major or minor version of Rancher Desktop. For example, when we released 1.11.0 we had to make changes. When we released 1.11.1 nothing had to be done other than the usual checks. ## How do I modify OBS when releasing a new major or minor version? Before you begin, you must have `osc` set up. Once you have that done, you can create a new package for the new major-minor version. Luckily, we don't have to create a new package from scratch: we can use the `osc copypac` command to copy an existing package. This command has the following signature: ``` osc copypac ``` For example, if we wanted to copy the `rancher-desktop-release-1.11` package from the `isv:Rancher:dev` project to `rancher-desktop-release-1.12`, also in the `isv:Rancher:dev` project, we would run `osc copypac` as follows: ``` osc copypac isv:Rancher:dev rancher-desktop-release-1.11 isv:Rancher:dev rancher-desktop-release-1.12 ``` Once this is done, you must update the `_service` file and the `Meta` tab in the package to refer to the new major-minor version. The easiest way to do this is via the OBS web interface, which you will need to be logged into. Generally speaking, you can simply replace all instances of `1.11` with `1.12` (assuming we're using the above example). Of course, it is best to understand what you are changing - the next section will help you with that. Once you have made these changes, the services will run and the builds should start and complete successfully. Finally, you should check the results. This is important - sometimes the build process falls over, sometimes VMs aren't available to build your package, and so on. If you run into issues, they are usually resolved by triggering a rebuild in the web interface. This can be done by clicking "Trigger Services" in the left navigation bar. Alternatively, you can trigger a rebuild for a specific package format by clicking on that package format (i.e. AppImage) from the main page of the package and then clicking "Trigger rebuild". You will need to be logged into the web interface to take these actions. You should also check that the link used to download the "latest" AppImage *actually* downloads the latest AppImage - the link is sometimes not updated, at least, not updated promptly. ## How do Linux releases actually *work*? ### The `dev` Channel The `dev` channel is intended to be used by developers and perhaps intrepid users. It corresponds to the [isv:Rancher:dev OBS project](https://build.opensuse.org/project/show/isv:Rancher:dev). 1. A new commit is pushed to a branch of the form `main` or `release-X.Y` (for example `release-1.2` or `release-1.11`), which triggers the `package.yml` github actions workflow. It builds Rancher Desktop and uploads the resultant .zip file to an S3 bucket under a name of the form `rancher-desktop-linux-.zip`. 3. As its last step, the `package.yml` workflow triggers a service run in the OBS package that corresponds to the branch that triggered the workflow run.it. This causes OBS to download and unpack the .zip file that was uploaded to S3 in step 2. It also causes OBS to pull some files related to the package formats will build from the rancher-desktop repository. 4. The new files trigger a build in OBS. 5. Once the build is complete in OBS, the new versions of the packages are available to users to download via `zypper install`, `apt install`, etc. ### The `stable` Channel The `stable` channel is where actual releases are hosted. It is intended for use by actual users. The `stable` channel corresponds to the [isv:Rancher:stable OBS project](https://build.opensuse.org/project/show/isv:Rancher:stable). The `stable` build process is similar to the `dev` channel, but works slightly differently: OBS builds are triggered by published github releases rather than new commits on branches of a particular format. 1. A new release is published, causing the `linux-release.yml` github actions workflow to run. This workflow fetches the linux .zip file from the release, and uploads it to AWS S3 with a name in the format `rancher-desktop-linux-X.Y.zip` (for example, `rancher-desktop-linux-1.12.zip`). 2. The `linux-release.yml` workflow triggers a service run in the OBS package that corresponds to the major and minor version of the tag of the published release. This causes OBS to download and unpack the .zip file uploaded to S3 in step 1. It also causes OBS to pull some files related to the package formats will build from the rancher-desktop repository. 4. The new files trigger a build in OBS. 5. Once the build is complete in OBS, the new versions of the packages are available to users to download via `zypper install`, `apt install`, etc. ================================================ FILE: docs/development/obs.md ================================================ # Tips for Working with OBS This document contains information on how to use OBS effectively. If you have not used OBS before, you should read [Getting Started](#getting-started) and [Important Concepts](#important-concepts) first. Then, come back to the other sections as you begin to work with the relevant parts of OBS. ## Getting Started The first thing you need to work with OBS is an installation of openSUSE Leap. Tumbleweed may work, but given its bleeding-edge nature, Leap is probably a better bet. The reason you need an installation of openSUSE is because any real work you do with OBS should be done using the `osc` command line tool, which is only available on openSUSE. There *is* a web interface, but it lacks much of the functionality that you will need. Use it for checking on the status of your package, and possibly small changes, but for everything else use `osc`. ## Important Concepts There are a few concepts that one should understand in order to use OBS. The way they work and interact can be unintuitive at first, so a brief overview is provided here. A **project** is the object in which you do everything in OBS. Everything falls under projects: repositories, packages, services; all of these things must belong to a project. Projects may have subprojects, which are themselves full projects. You have to be an OBS admin to create a root-level project, so our project (`Rancher`) was created as a subproject of the `isv` root project. Projects are referred to as each of their parent projects plus their name, all separated by colons. So to refer to our top-level project, you use the name `isv:Rancher`. A **repository** is configured on a project. The best way to think of repositories is in the context of package managers: they are a remote endpoint from which you can download packages. There are several types of repositories - some are true repositories in the sense that tools like `apt` and `dnf` can be configured to use them, and others are just endpoints you can download assets from. Also, you can configure multiple repositories on each project. This is useful for building and serving packages of multiple formats from the same binary or source code. A **package** is also configured on a project. Conceptually, OBS packages are different from packages in other contexts. In OBS, a package represents a set of files that go into a build, such as source files and any package metadata files (such as rpm `.spec` files). Also, from the perspective of the user's package manager, an OBS package represents exactly one version of the package. So if you want to provide multiple versions of the package in each repository, you must have one OBS package for each version. A **service** is basically a script that can be triggered in a few different ways. A common use for services is to get the latest version of code from version control before building and packaging that code. For more information on services see below; also, you may find the [documentation for services][service_documentation] helpful. [service_documentation]: https://openbuildservice.org/help/manuals/obs-user-guide/cha.obs.source_service.html#sec.obs.sserv.about ## Service Tips ### Update your services to the latest versions Before doing anything with services, you should ensure that you have installed the latest versions of any services you want to work with. This is important because the remote version of OBS (build.opensuse.org) always uses the latest version of services - if you are working with a different version on your local machine, you may run into issues. Also note that services do not always (ever?) use semantic versioning despite having versions of the form `X.Y.Z`. The repositories that openSUSE comes configured with do not contain the latest versions of the OBS services. In order to get the latest versions you need to add a repository: ``` zypper addrepo https://download.opensuse.org/repositories/openSUSE:/Tools/openSUSE_15.3/openSUSE:Tools.repo zypper refresh ``` After you do this you can install/update the services you need. If you aren't on Leap 15.3, you may have to find a different version of this repo, but this is what works at the time of writing. ### How to find out what services are available Services come in the form of rpm packages that can be installed via `zypper`. In order to search your installed repos for services, simply run: ``` zypper search obs-service ``` ### How to find out what configuration each service takes Once services are installed you can look at their interface schema in order to understand how to use them. The interface schema (as well as the source code) are stored in the directory `/usr/lib/obs/service/`. ## Local Build Tips ### How to get around slow mirrors When you do a local build, the first thing `osc` does is cache any dependencies of the build. `osc` will download these dependencies from mirrors of their repositories. Unfortunately these mirrors can be very slow. If the dependency caching step is too slow, you can tell `osc build` to only fetch packages from the build.opensuse.org api with the `--download-api-only` flag. ### How to skip running services before build Use the `--no-service` flag on `osc build` for this. ### How to find the output of a local build When you build locally, it is not always obvious where the output of the build has been saved. To find the location of your build output, look at the text that the build has printed at the screen. At the end of it there should be a path; this is where you can find your built package. ## Additional Resources - The `help-obs` and `discuss-zypp` slack channels are always friendly and helpful. - The [OBS documentation][obs_docs] might help resolve any problems you run into. - The output of `osc --help` and `osc --help` may be helpful. - The [Using the Open Build Service][using_obs] may be helpful for understanding how we build AppImages using OBS. [obs_docs]: https://openbuildservice.org/help/manuals/obs-user-guide/ [using_obs]: https://docs.appimage.org/packaging-guide/hosted-services/opensuse-build-service.html ================================================ FILE: docs/development/release-checklist.md ================================================ ## Release Checklist - [ ] Update version number in package.json if not done after last release. - [ ] Tag release branch. Wait for the CI to build artifacts. - [ ] Sign windows installer. ### Sign mac installer (As there's an issue with the zip produced by the build script, we need to manually build and zip, rename the file to replace space with dot etc ) - [ ] Make sure the required env variables are set for the notarize, signing process. - [ ] git clean, reset to make sure a clean (CI equivalent) build. - [ ] Manually zip the installer. - [ ] Rename installer filename to replace space with dot. ### Release Documentation - [ ] Release notes. Update on the GitHub draft Release page. - [ ] docs update (Help, Readme..) - [ ] Slack Announcements - [ ] Newsletter summary - [ ] Update metrics, roadmap on Confluence page ### Release - [ ] Perform smoke test on release artifacts. - [ ] Upload mac, win release artifacts on the GitHub draft Release page. - [ ] Update the release version for upgrade responder. - [ ] Move from draft release to Release. - [ ] Check the auto update functionality. ================================================ FILE: docs/development/signing.md ================================================ # Signing Releases Normally, we build artifacts via GitHub Actions as zip files, which can then be signed offline. This is necessary as the relevant certificates are not available online during CI. In general, the process involves: 1. Download the zip archive from GitHub. **Note** The archive will be a zip within a zip. Extract one level to get the file as generated by electron-builder. 2. Set up the signing environment; see the platform-specific sections below. 3. Run the signing tool: ```sh yarn sign path/to/archive.zip ``` 4. Look in `dist/` for the signed files (`Rancher.Desktop.Setup.msi`, etc.). ## Windows On Windows, it is necessary to obtain a code signing certificate that can be used with the Windows infrastructure. It is then necessary to determine the fingerprint of the certificate, and set it as the `CSC_FINGERPRINT` environment variable before running `yarn sign`. ### Generate a Test Certificate For testing purposes, we can generate a certificate locally by running the following command in PowerShell: ```powershell New-SelfSignedCertificate ` -Type Custom ` -Subject "CN=Rancher-Sandbox, C=CA" ` -KeyUsage DigitalSignature ` -CertStoreLocation Cert:\CurrentUser\My ` -FriendlyName "Rancher-Sandbox Code Signing" ` -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") ``` This will display the new certificate, with a `Thumbprint` column; this is the certificate fingerprint that we will need to set as `CSC_FINGERPRINT`. If you need to refer to the certificate later, it can be obtained by running `ls Cert:\CurrentUser\My` in a PowerShell prompt. ### Using a Certificate Stored on a YubiKey If you have a certificate (with private key) that is stored on a YubiKey device, it is first necessary to install the [YubiKey Minidriver]. After plugging in your device, you should then be able to run `certutil -scinfo -silent` to locate the fingerprint for your desired certificate. Note that this will list all the certificates on the device, including the chain to your certificate, and you must search the output for your signing certificate and locate the `Cert Hash(sha1):` entry. That hash is then used for `CSC_FINGERPRINT` above. [YubiKey Minidriver]: https://www.yubico.com/support/download/smart-card-drivers-tools/ ### Verifying the Signed Product In explorer, right-click on the final `.exe` file, choose `Properties`, `Digital Signatures`, and verify that `Suse LLC` is listed in the Signature List. ## macOS On macOS, a signing certificate from Apple is required (via their developer program). Please refer to [Apple Documentation] for details. Note that a _Mac Development_ certificate is insufficient for notarization; it must be a _Developer ID Application_ certificate. This will be reflected in the Common Name of the certificate. Launch constraints require macOS Ventura (macOS 13) or newer. This is therefore needed for production signing. [Apple Documentation]: https://developer.apple.com/help/account/create-certificates/create-developer-id-certificates ### Generate a test certificate If a real certificate from Apple is unavailable, it is possible to generate a self-signed test certificate; however, note that this wouldn't properly exercise all of the signing code. ```sh openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \ -keyform pem -sha256 -days 3650 -nodes -subj \ "/C=XX/ST=NA/L=Some Town/O=No Org/OU=No Unit/CN=RD Test Signing Key" \ -addext keyUsage=critical,digitalSignature \ -addext extendedKeyUsage=critical,codeSigning security import key.pem -t priv -A security import cert.pem -t cert -A security set-key-partition-list -S apple-tool:,apple:,codesign: -s security add-trusted-cert -p codeSign cert.pem ``` ### Configuring Access - Import your signing certificate into your macOS Keychain. - Run `security find-identity -v` to locate the fingerprint of the key to use. Export the long hex string as the `CSC_FINGERPRINT` environment variable. - For a test certificate, use `security find-identity` without `-v`; the certificate to use isn't valid. For notarization, the following environment variables are also needed: - `APPLEID` - This is your Apple ID login; for example, `john.doe@example.com` - `AC_PASSWORD` - This is an application-specific password for your Apple ID; to create it: 1. Navigate to https://appleid.apple.com/account/manage 2. Click on _App-Specific Passwords_ at the bottom. 3. Create one (with a label of your choice) and copy the resulting password. - `AC_TEAMID` - This is the Apple Team ID. This is the _Organizational Unit (OU)_ field of the subject of your signing certificate; for Rancher Desktop / SUSE, this is `2Q6FHJR3H3`. (This value can be extracted from the published application.) ### Performing signing When signing for M1/aarch64, please set the `M1` environment variable ahead of time as usual. If notarization is not required, append `--skip-notarize` to the command: ```sh yarn sign --skip-notarize path/to/archive.zip ``` This is necessary to test the signing flow (since there's no way to notarize without the production certificate). This is also necessary to use a test certificate (since Apple will reject it). When using an older version of macOS (12/Monterey or older), `--skip-constraints` is also needed to skip assigning launch constraints, as that requires Ventura or later. This is inappropriate for the actual release. ================================================ FILE: docs/networking/windows/README.md ================================================ # Rancher Desktop Network Documentation The table of contents below provides references to all the projects that comprise the Rancher Desktop network stack on windows platform. - [Rancher Desktop Guest Agent](rancher-desktop-guest-agent.md) - [Rancher Desktop Networking](rancher-desktop-networking.md) ## Feature Parity Below is table to demonstrate the feature parity between both classic networking and tunneled networking.
feature classic networking tunneled network
admin non-admin admin non-admin
Docker port forwarding localhost
0.0.0.0 🚫 🚫
Containerd port forwarding localhost
0.0.0.0 🚫 🚫
Kubernetes port forwarding localhost
0.0.0.0 🚫 🚫
iptables port forwarding localhost
0.0.0.0 🚫 🚫
WSL integration localhost
VPN support N/A 🚫 🚫
================================================ FILE: docs/networking/windows/rancher-desktop-guest-agent.md ================================================ # **[Rancher Desktop Guest Agent](../../../src/go/guestagent)** The Rancher Desktop Guest Agent operates within the Rancher Desktop WSL distribution, particularly in an isolated namespace when the network tunnel is enabled. It facilitates interactions between various container engine APIs like Moby, containerd, and Kubernetes. The agent monitors container/service creation events from these APIs and, upon detecting ports needing exposure, forwards the port mappings to internal services accordingly. This ensures efficient and automated port forwarding management within the Rancher Desktop environment. ```mermaid flowchart LR; subgraph Host["HOST"] host-switch["host-switch"] end subgraph VM["WSL"] subgraph netNs["Isolated Network Namespace"] guest-agent["Guest Agent"] docker(("Docker API")) containerd(("Containerd API")) kubernetes(("K8s API")) iptables(("iptable scanning")) guest-agent <----> docker guest-agent <----> containerd guest-agent <----> kubernetes guest-agent <----> iptables guest-agent ----> host-switch end subgraph defaultNs["Default Namespace"] wsl-proxy["wsl-proxy"] end guest-agent ----> |UNIX socket| wsl-proxy end ``` ### Supported Flags - **debug**: Enables debug logging. - **docker**: When this flag is enabled, port mapping via docker API monitoring is enabled. See the port mapping and Docker sections below for details. - **kubernetes**: Enables Kubernetes service port forwarding. When enabled, the Rancher Desktop Guest Agent creates a watcher for the Kubernetes API, monitoring NodePort and LoadBalancer services needing port forwarding. For services with exposed ports, the agent creates corresponding port mappings, forwarding them to Rancher Desktop Networking’s `host-switch`, which hosts an API for exposing ports from the host into the network namespace. If WSL integration is enabled, the port mapping is also forwarded to Rancher Desktop Networking’s `wsl-proxy`, allowing access from other WSL distributions. - **kubeconfig**: Specifies the path to `kubeconfig` for locating the Kubernetes API endpoint. By default, it looks in `/etc/rancher/k3s/k3s.yaml`. - **iptables**: This flag enables the scanning of iptables. In newer versions of Kubernetes, kubelet no longer creates listeners for NodePort and LoadBalancer services. To rectify this, we manually create those listeners so the port forwarding functions correctly. The guest agent creates a corresponding port mapping that represents the service’s exposed port. The port mapping is then forwarded to Rancher Desktop Networking’s `host-switch`, which hosts an API for exposing ports from the host into the network namespace. If WSL integration options are enabled within Rancher Desktop, a copy of that port mapping is also forwarded to Rancher Desktop Networking’s `wsl-proxy`. The `wsl-proxy` exposes the service port to enable users to access it from other WSL distros. - **containerd**: When this flag is enabled, the guest agent monitors container events from the containerd API. It connects to the Containerd API via the containerd socket (`/run/k3s/containerd/containerd.sock`). Whenever a container is created or deleted, if there are exposed ports associated with that container, the guest agent creates a corresponding port mapping. This port mapping is then forwarded to Rancher Desktop Networking's `host-switch`, which hosts an API for exposing ports from the host into the network namespace. If WSL integration options are enabled within Rancher Desktop, a copy of this port mapping is also forwarded to Rancher Desktop Networking's `wsl-proxy`. The `wsl-proxy` exposes the container's port to enable users to access it from other WSL distros. - **containerdSock**: File path for the containerd socket address. If no argument is provided, it defaults to `/run/k3s/containerd/containerd.sock`. - **vtunnelAddr**: Peer address for the Vtunnel process that forwards port mappings to the Vtunnel Host process over `AF_VSOCK`. This feature will soon be deprecated. - **k8sServiceListenerAddr**: Specifies an IP address (`0.0.0.0` or `127.0.0.1`) to bind Kubernetes services on the host. - **adminInstall**: This flag indicates whether Rancher Desktop is installed with administrator privileges. It is used to enable Network Tunnel mode, where port mappings are forwarded to Rancher Desktop Networking's `host-switch`. The `host-switch` hosts an API that exposes ports from the host into the network namespace. - **k8sAPIPort**: Specifies the Kubernetes API port, which is forwarded to `wsl-proxy` to allow other distros that are part of WSL integrations to interact via `kubectl`. ## PortMapping Is a struct object that represents an exposed container or a service. [Portmapping](../../../src/go/guestagent/pkg/types/portmapping.go#L23) objects consist of the following fields: ``` type PortMapping struct { // Remove indicates whether to remove or add the entry Remove bool `json:"remove"` // Ports are the port mappings for both IPV4 and IPV6 Ports nat.PortMap `json:"ports"` // ConnectAddrs are the backend addresses to connect to ConnectAddrs []ConnectAddrs `json:"connectAddrs"` } ``` ## Networking Mode Rancher Desktop Guest Agent can operate in one of two networking modes, depending on startup arguments: **-adminInstall** The Network Tunnel mode allows the Guest Agent to operate in an isolated network namespace with a dedicated iptables. This mode is enabled through Rancher Desktop Networking. None **-adminInstall** Rancher Desktop Guest Agent operates in non-admin user mode. In this mode, all port mappings are bound to localhost, and the use of privileged ports is restricted. ## Containerd When containerd mode is enabled, the guest agent monitors the containerd API for the following container events: ``` /tasks/start /containers/update /tasks/exit ``` If it detects any exposed ports associated with a container, it creates a port mapping object. Depending on the selected network mode, the port mapping object is then forwarded to the host. If the privileged service is enabled, it utilizes the vtunnel peer process to communicate the port mappings with privileged services. Alternatively, if network tunnel mode is enabled, it sends the port mappings to the API offered in the host switch process. If network tunnel mode is enabled along with the WSL integration option, a copy of the port mapping is also forwarded to the WSL proxy process, enabling access to the exposed port from other distributions. ## Docker Similar to containerd mode, when Docker mode is enabled, the guest agent watches Docker API with the following container events filter: ``` Filters: filters.NewArgs( filters.Arg("type", "container"), filters.Arg("event", startEvent), filters.Arg("event", stopEvent), filters.Arg("event", dieEvent) ), ``` If it detects any exposed ports associated with a container, it creates a port mapping object. Depending on the selected network mode, the port mapping object is then forwarded to the host. If the privileged service is enabled, it uses the vtunnel peer process to communicate the port mappings with privileged services. Otherwise, if network tunnel mode is enabled, it sends the port mappings to the API offered in the host switch process. If network tunnel mode is enabled along with the WSL integration option, a copy of the port mapping is also forwarded to the `wsl-proxy` process, allowing access to the exposed port from other distributions. Additionally, Docker mode creates a series of iptables rules associated with the `PREROUTING` and `POSTROUTING` chains. The `PREROUTING` rule rewrites the destination IP address of any packets received by the local system and destined for `192.168.127.2` to `127.0.0.1`. Meanwhile, the `POSTROUTING` chain rule rewrites the source IP address of any packets being sent out through the eth0 network interface to the IP address of that interface (eth0). These rules are necessary because when the port binding is set to `127.0.0.1`, an additional `DNAT` rule is added in the main `DOCKER` chain after the existing rule using `--append`. This adjustment is essential because the initial `DOCKER DNAT` rule created by Docker only allows traffic to be routed to `localhost` from `localhost`. Therefore, an additional rule is added to permit traffic to any destination IP address, enabling the service to be discoverable through the namespaced network's subnet. These changes are necessary as the traffic is routed via the vm-switch over the tap network. The existing `DNAT` rule is as follows: ``` DNAT tcp -- anywhere localhost tcp dpt:9119 to:10.4.0.22:80 ``` The following rule is added after the existing rule: ``` DNAT tcp -- anywhere anywhere tcp dpt:9119 to:10.4.0.22:80 ``` ## Kubernetes When this option is enabled, the Rancher Desktop guest agent uses the Kubernetes service watcher to subscribe to the Kubernetes API for any services of type NodePort and LoadBalancer that require port exposure. If the service watcher detects such services, it creates a port mapping object representing each service. The port mapping object is then forwarded to the host based on the selected network mode. If the privileged service is enabled, it uses the vtunnel peer process to communicate the port mappings with privileged services. Otherwise, if network tunnel mode is enabled, it sends the port mappings to the API provided in the host switch process. It is important to note that if the `k8sServiceListenerAddr` flag is provided, the specified IP address (either `0.0.0.0` or `127.0.0.1`) is used to bind the Kubernetes services on the host. Additionally, if network tunnel mode is enabled along with the WSL integration option, a copy of the port mapping is also forwarded to the `wsl-proxy` process to allow access to the exposed port from other distributions. However, when the Kubernetes option is enabled, the guest agent statically emits a port mapping to the `wsl-proxy` process in the default network. This port mapping represents the Kubernetes API port (`6443`) to allow access to the Kubernetes API from other distributions. Below port mapping is an example of what is emitted to `wsl-proxy`: ``` types.PortMapping { Remove: false, Ports: nat.PortMap { port: [] nat.PortBinding { { HostIP: "127.0.0.1", HostPort: 6443, }, }, }, } ``` ## iptables In [newer versions](https://github.com/rancher-sandbox/rancher-desktop/blob/bb7f71f18828c45b711d6d4982a2dcaf19f8f3fa/pkg/rancher-desktop/backend/k3sHelper.ts#L1152) of Kubernetes, kubelet no longer automatically creates listeners for NodePort and LoadBalancer services. To address this, we manually create these listeners to ensure proper port forwarding functionality. Service ports requiring forwarding are identified in iptables DNAT. When iptables identifies such ports, it creates a port mapping object representing that service. Depending on the selected network mode, the port mapping object is then forwarded to the host. If the privileged service is enabled, it uses the vtunnel peer process to communicate the port mappings with privileged services. Otherwise, if network tunnel mode is enabled, it sends the port mappings to the API provided by the host switch process. If network tunnel mode is enabled along with the WSL integration option, a copy of the port mapping is also forwarded to the `wsl-proxy` process, allowing access to the exposed port from other distributions. ## Port forwarding (Network Tunnel) ```mermaid sequenceDiagram box VM participant dockerd participant containerd participant kubernetes participant iptables participant guest-agent participant wsl-proxy end box Host participant host-switch as host-switch.exe end rect transparent note over dockerd,containerd: adding port alt containerd containerd ->> guest-agent: /tasks/start guest-agent ->> guest-agent: loopback iptables else dockerd dockerd ->> guest-agent: event[start] guest-agent ->> guest-agent: loopback iptables else kubernetes kubernetes ->> guest-agent: event[not deleted] else iptables iptables ->> iptables: poll iptables iptables ->> guest-agent: add new ports end guest-agent ->> wsl-proxy: add port wsl-proxy ->> wsl-proxy: listen in default namespace guest-agent ->> host-switch: add port (APITracker) host-switch ->> host-switch: add port via gvisor end rect transparent note over dockerd, containerd: updating port alt containerd containerd ->> guest-agent: /containers/update end guest-agent ->> wsl-proxy: remove port wsl-proxy ->> wsl-proxy: remove listener in default namespace guest-agent ->> host-switch: remove port (APITracker) host-switch ->> host-switch: remove port via gvisor guest-agent ->> wsl-proxy: add port wsl-proxy ->> wsl-proxy: listen in default namespace guest-agent ->> host-switch: add port (APITracker) host-switch ->> host-switch: add port via gvisor end rect transparent note over dockerd,containerd: removing port alt containerd containerd ->> guest-agent: /tasks/exit else dockerd dockerd ->> guest-agent: event[stop] dockerd ->> guest-agent: event[die] else kubernetes kubernetes ->> guest-agent: event[deleted] else iptables iptables ->> iptables: poll iptables iptables ->> guest-agent: remove old ports end guest-agent ->> wsl-proxy: remove port wsl-proxy ->> wsl-proxy: remove listener in default namespace guest-agent ->> host-switch: remove port (APITracker) host-switch ->> host-switch: remove port via gvisor end ``` ================================================ FILE: docs/networking/windows/rancher-desktop-networking.md ================================================ # [Rancher Desktop Networking](../../../src/go/networking/) Rancher Desktop Networking primarily acts as a layer 2 switch between the host (currently Windows only) and the VM (WSL) using the `AF_VSOCK` protocol. It facilitates the transmission of Ethernet frames from the VM to the host. Additionally, it provides `DNS`, `DHCP`, and dynamic port forwarding functionalities. The Rancher Desktop Networking comprises several key services: `host-switch`, `vm-switch`, `network-setup`, and `wsl-proxy`. It utilizes [gvisor's](https://github.com/google/gvisor) network stack and draws inspiration from the [gvisor-tap-vsock](https://github.com/google/gvisor) project. The diagram below demonstrates the overall architecture of Rancher Desktop Networking: ```mermaid flowchart LR subgraph hostSwitch["host-switch.exe"] vsockHost{"main loop"} eth(("reconstruct ETH frames")) portForwarding["Port Forwarding API"] end subgraph Host["HOST"] dns["DNS"] syscall(("OS syscall")) hostSwitch end subgraph netNs["Isolated Network Namespace"] vsockVM{"VM Switch"} tapDevice("eth0") veth-rd-ns("veth-rd-ns") containers["containers"] services["services"] end subgraph WSL["WSL"] netNs other-distro(("Other Distros")) veth-rd-wsl("veth-rd-wsl") wsl-proxy{"wsl-proxy"} end vsockHost <----> eth & dns eth <----> syscall vsockHost ----> portForwarding tapDevice -- ethernet frames --> vsockVM veth-rd-ns -- ethernet frames --> vsockVM veth-rd-wsl <----> veth-rd-ns other-distro <----> wsl-proxy wsl-proxy <----> veth-rd-wsl containers <----> tapDevice services <----> tapDevice vsockVM <-- AF_VSOCK ---> vsockHost ``` ## host-switch: The host-switch runs on the Windows host and acts as a receiver for all traffic originating from the network namespace within the WSL VM. It performs a handshake to identify the correct VM to communicate with over `AF_VSOCK`. This process retrieves the GUID for the appropriate Hyper-V VM (most likely WSL). It then performs a handshake with the network-setup process running in the WSL distribution to ensure the `AF_VSOCK` connection is established with the correct VM. Once the ready signal is received from the vm-switch, an `AF_VSOCK` connection is established to listen for incoming traffic from that VM. Additionally, the host-switch provides a DNS resolver that runs in the user space network and an API for dynamic port forwarding. The port forwarding API offers the following endpoints: - `/services/forwarder/all`: Lists all the currently forwarded ports. - `/services/forwarder/expose`: Exposes a port. - `/services/forwarder/unexpose`: Unexposes a port. ## Supported Flags: - **debug**: Enables debug logging. - **subnet**: This flag defines a subnet range with a CIDR suffix for a virtual network. If it is not defined, it uses `192.168.127.0/24` as the default range. It is important to note that this value needs to match the [subnet](https://github.com/rancher-sandbox/rancher-desktop/blob/6abacdc804d6414f17439a97f22e0c9c87f6249d/cmd/vm/switch_linux.go#L59) flag in the vm-switch. - **port-forward**: This is a list of static ports that need to be pre-forwarded to the WSL VM. These ports are not dynamically retrieved from any of the APIs that the Rancher Desktop guest agent interacts with. ## network-setup: The reason for its creation was that the `AF_VSOCK` connection could not be established between the host and a process residing inside the network namespace within the VM, as such capability is not currently supported by `AF_VSOCK`. As a result, the network setup was created. Its main responsibility is to respond to the handshake request from the `host-switch.exe`. Once the handshake process is successful with the `host-switch`, the `network-setup` process creates a new network namespace and attempts to start its subprocess, `vm-switch`, in the newly created network namespace. It also hands over the `AF_VSOCK` connection to the `vm-switch` as a file descriptor in the new namespace. Additionally, it calls unshare with provided arguments through [---unshare-args](https://github.com/rancher-sandbox/rancher-desktop/blob/6abacdc804d6414f17439a97f22e0c9c87f6249d/cmd/network/setup_linux.go#L272). The process also establishes a Virtual Ethernet pair consisting of two endpoints: `veth-rd-ns` and `veth-rd-wsl`. `veth-rd-wsl` resides within the default namespace and is configured to listen on the IP address `192.168.143.2`. Conversely, `veth-rd-ns` is located within a network namespace and is assigned the IP address `192.168.143.1`. The virtual Ethernet pair allows accessibility from the default network into the network namespace, which is particularly useful when WSL integration is enabled. ## Supported Flags: - **debug**: enable the debug logging - **tap-interface**: The name of the tap interface that is created by the vm-switch upon startup, e.g., `eth0`, `eth1`. This value is passed to the `vm-switch` process when the `network-setup` attempts to start it. If no value is provided, the default name of `eth0` is used. - **subnet**: A subnet range with a CIDR suffix that is associated with the tap interface in the network namespace. If it is not defined, it uses `192.168.127.0/24` as the default range. It is important to note that this value needs to match the [subnet](https://github.com/rancher-sandbox/rancher-desktop/blob/6abacdc804d6414f17439a97f22e0c9c87f6249d/cmd/host/switch_windows.go#L54) flag in the `host-switch`. - **tap-mac-address**: MAC address associated with the tap interface created by the vm-switch in the network namespace. If no address is provided, the default address of `5a:94:ef:e4:0c:ee`is used. - **vm-switch-path**: The path to the `vm-switch` binary that will run in a new namespace. This value is used with `nsenter` to switch the namespace and start the `vm-switch` in the network namespace. - **vm-switch-logfile**: The path to the logfile for the vm-switch process. - **unshare-arg**: The command argument to pass to the unshare program in addition to the following [arguments](https://github.com/rancher-sandbox/rancher-desktop/blob/6abacdc804d6414f17439a97f22e0c9c87f6249d/cmd/network/setup_linux.go#L272). - **logfile**: Path to the logfile for the `network-setup` process. ## vm-switch: Once the network-setup starts the `vm-switch` process in the new namespace, the `vm-switch` creates a tap device (`eth0`) and a loopback device (`lo`). When the `eth0` tap device is successfully created, it uses the `DHCP` client to acquire an IP address within the defined range from the `DHCP` server. Once the `eth0` tap device is up and running, the kernel forwards all raw Ethernet frames originating from the network namespace to the tap device. In addition to the traffic from the network namespace, the kernel also forwards all the traffic that arrives at `veth-rd-ns` from its pair, `veth-rd-wsl`, in the default namespace. The tap device forwards the Ethernet frames over [vsock](https://wiki.qemu.org/Features/VirtioVsock) to the host. The process on the host (`host-switch.exe`) decapsulates the frames. Since host-switch maintains both internal (`vm-switch` to `host-switch.exe`) and external (`host-switch.exe` to the internet) connections, it connects to the external endpoints via syscalls. ## Supported Flags: - **debug**: Enable the debug logging - **tap-interface**: Tap interface name to create, eg. eth0, eth1 - **tap-mac-address** : MAC address that is associated with the tap interface - **subnet**: The subnet range with CIDR suffix associated with the tap interface. Although this value is passed from network-setup, it must match the subnet flag in `host-switch` and `network-setup`. - **logfile**: Path to `vm-switch` process logfile ## wsl-proxy: Its primary function comes into play when WSL integration is activated alongside the network tunnel. Running within the default network namespace, it establishes a Unix socket listener (`/run/wsl-proxy.sock`) for the guest agent process to connect to from inside the network namespace. The guest agent forwards port mappings from various APIs (docker, containerd, and K8s) over the Unix socket to the `wsl-proxy`. Upon receiving the port mappings, the wsl-proxy sets up listeners bound to localhost for those ports. When traffic arrives at these listeners, it forwards the traffic to the bridge interface connecting the default namespace to the namespaced network, facilitating bidirectional traffic flow. ## Supported Flags: - **debug**: Enable the debug logging - **logfile**: Path to the logfile for `wsl-proxy` process - **socketFile**: This is the path to the `.sock` file for the UNIX socket connection established between the Rancher Desktop guest agent and the `wsl-proxy`. If not provided, the default value of `/run/wsl-proxy.sock` is used. - **upstreamAddress**: This is the IP address associated with the upstream server to use. It corresponds to the address of the veth pair connecting the default namespace to the network namespace, specifically `veth-rd-ns`. The default value is `192.168.143.1`. ## Process Timelines: Below is a flow chart that demonstrates the process start up orders. ```mermaid sequenceDiagram participant wsl-init (pid n) participant network-setup participant vm-switch participant wsl-init (pid 1) participant host-switch.exe Note over wsl-init (pid n),wsl-init (pid 1): WSL distro (Network Namespace) Note over host-switch.exe: windows host wsl-init (pid n)->>network-setup: spawn process host-switch.exe->>network-setup: handshake request network-setup->>host-switch.exe: handshake response (READY signal) host-switch.exe->>network-setup: vsock listener ready network-setup->>network-setup: open vsock network-setup->>network-setup: create namespace network-setup->>network-setup: create veth pair (veth-rd) network-setup->>vm-switch: spawn Note over network-setup,vm-switch: spawn in network namespace Note over network-setup,vm-switch: pass in vsock connection as fd network-setup->>wsl-init (pid 1): spawn Note over network-setup,wsl-init (pid 1): spawns in netns, new mnt/pid ns vm-switch->>vm-switch: create lo/eth0 vm-switch->>vm-switch: DHCP eth0 vm-switch->>vm-switch: listen for ethernet frames vm-switch->>host-switch.exe: forward ethernet wsl-init (pid 1)-->>wsl-init (pid 1): Spawn /sbin/init ``` ================================================ FILE: e2e/assets/k8s-deploy-sample/nginx-sample-app.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: nginx-app spec: selector: matchLabels: app: nginx version: v1 replicas: 1 template: metadata: labels: app: nginx version: v1 spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx-app labels: app: nginx spec: type: NodePort ports: - port: 80 name: http selector: app: nginx ================================================ FILE: e2e/backend.e2e.spec.ts ================================================ import fs from 'fs'; import os from 'os'; import path from 'path'; import { test, expect } from '@playwright/test'; import _ from 'lodash'; import semver from 'semver'; import { NavPage } from './pages/nav-page'; import { getAlternateSetting, startSlowerDesktop, teardown } from './utils/TestUtils'; import { Settings, ContainerEngine, VMType, MountType } from '@pkg/config/settings'; import paths from '@pkg/utils/paths'; import { RecursivePartial, RecursiveKeys } from '@pkg/utils/typeUtils'; import type { ElectronApplication, Page } from '@playwright/test'; test.describe.serial('KubernetesBackend', () => { let electronApp: ElectronApplication; let page: Page; test.beforeAll(async({ colorScheme }, testInfo) => { [electronApp, page] = await startSlowerDesktop(testInfo, { kubernetes: { enabled: false, }, virtualMachine: { mount: { type: MountType.REVERSE_SSHFS, }, }, }); }); test.afterAll(({ colorScheme }, testInfo) => teardown(electronApp, testInfo)); test('should start loading the background services and hide progress bar', async() => { const navPage = new NavPage(page); await navPage.progressBecomesReady(); await expect(navPage.progressBar).toBeHidden(); }); test.describe('requiresRestartReasons', () => { let serverState: { user: string, password: string, port: string, pid: string }; test.afterEach(async() => { // Wait for the backend to stop (it's okay to fail to start here though) const navPage = new NavPage(page); while (await navPage.progressBar.count() > 0) { await navPage.progressBar.waitFor({ state: 'detached', timeout: 120_000 }); } }); test('should emit connection information', async() => { const dataPath = path.join(paths.appHome, 'rd-engine.json'); const dataRaw = await fs.promises.readFile(dataPath, 'utf-8'); serverState = JSON.parse(dataRaw); expect(serverState).toEqual(expect.objectContaining({ user: expect.any(String), password: expect.any(String), port: expect.any(Number), pid: expect.any(Number), })); }); async function get(requestPath: string) { const auth = Buffer.from(`${ serverState.user }:${ serverState.password }`).toString('base64'); const result = await fetch(`http://127.0.0.1:${ serverState.port }/${ requestPath.replace(/^\//, '') }`, { headers: { Authorization: `basic ${ auth }` } }); expect(result).toEqual(expect.objectContaining({ ok: true })); return await result.json(); } async function put(requestPath: string, body: any) { const auth = Buffer.from(`${ serverState.user }:${ serverState.password }`).toString('base64'); const result = await fetch(`http://127.0.0.1:${ serverState.port }/${ requestPath.replace(/^\//, '') }`, { body: JSON.stringify(body), headers: { Authorization: `basic ${ auth }` }, method: 'PUT', }); const text = await result.text(); try { return JSON.parse(text); } catch (cause) { throw new Error(`Response text is not JSON: \n${ text }`, { cause }); } } test('should detect changes', async() => { const currentSettings = (await get('/v1/settings')) as Settings; if (!currentSettings.kubernetes.version) { // The Kubernetes version could be empty if it's previously disabled. // Set something. const updatedSettings: RecursivePartial = { kubernetes: { version: '1.29.4' }, version: 10 as Settings['version'], }; await expect(put('/v1/settings', updatedSettings)).resolves.toBeDefined(); } const newSettings: RecursivePartial = { containerEngine: { name: getAlternateSetting(currentSettings, 'containerEngine.name', ContainerEngine.CONTAINERD, ContainerEngine.MOBY) }, kubernetes: { version: getAlternateSetting(currentSettings, 'kubernetes.version', '1.29.6', '1.29.5'), port: getAlternateSetting(currentSettings, 'kubernetes.port', 6443, 6444), enabled: getAlternateSetting(currentSettings, 'kubernetes.enabled', true, false), options: { traefik: getAlternateSetting(currentSettings, 'kubernetes.options.traefik', true, false), flannel: getAlternateSetting(currentSettings, 'kubernetes.options.flannel', true, false), }, }, }; /** Platform-specific changes to `newSettings`. */ const platformSettings: Partial>> = { win32: { kubernetes: { ingress: { localhostOnly: getAlternateSetting(currentSettings, 'kubernetes.ingress.localhostOnly', true, false) } } }, darwin: { virtualMachine: { type: getAlternateSetting(currentSettings, 'virtualMachine.type', VMType.VZ, VMType.QEMU) } }, }; _.merge(newSettings, platformSettings[process.platform] ?? {}); if (['darwin', 'linux'].includes(process.platform)) { // Lima-specific changes to `newSettings`. _.merge(newSettings, { virtualMachine: { numberCPUs: getAlternateSetting(currentSettings, 'virtualMachine.numberCPUs', 1, 2), memoryInGB: getAlternateSetting(currentSettings, 'virtualMachine.memoryInGB', 3, 4), }, application: { adminAccess: getAlternateSetting(currentSettings, 'application.adminAccess', false, true) }, }); } /** * Helper type; an (incomplete) mapping where the key is the preference * name, and the value is a boolean value indicating whether reset is needed. */ type ExpectedDefinition = Partial, boolean>>; const expectedDefinition: ExpectedDefinition = { 'kubernetes.version': semver.lt(newSettings.kubernetes?.version ?? '0.0.0', currentSettings.kubernetes.version), 'kubernetes.port': false, 'containerEngine.name': false, 'kubernetes.enabled': false, 'kubernetes.options.traefik': false, 'kubernetes.options.flannel': false, }; /** Platform-specific additions to `expectedDefinition`. */ const platformExpectedDefinitions: Partial> = { win32: { 'kubernetes.ingress.localhostOnly': false }, darwin: { 'virtualMachine.type': false }, }; _.merge(expectedDefinition, platformExpectedDefinitions[process.platform] ?? {}); if (['darwin', 'linux'].includes(process.platform)) { // Lima additions to expectedDefinition expectedDefinition['application.adminAccess'] = false; expectedDefinition['experimental.virtualMachine.diskSize'] = false; expectedDefinition['virtualMachine.numberCPUs'] = false; expectedDefinition['virtualMachine.memoryInGB'] = false; } const expected: Record = {}; for (const [key, reset] of Object.entries(expectedDefinition)) { const entry = { current: _.get(currentSettings, key), desired: _.get(newSettings, key), severity: reset ? 'reset' : 'restart' as 'reset' | 'restart', }; expected[key] = entry; } await expect(put('/v1/propose_settings', newSettings)).resolves.toEqual(expected); }); test('should handle WSL integrations', async() => { test.skip(os.platform() !== 'win32', 'WSL integration only supported on Windows'); const random = `${ Date.now() }${ Math.random() }`; const newSettings: RecursivePartial = { WSL: { integrations: { [`true-${ random }`]: true, [`false-${ random }`]: false, }, }, }; await expect(put('/v1/propose_settings', newSettings)).resolves.toMatchObject({ 'WSL.integrations': { desired: newSettings.WSL?.integrations, severity: 'restart', }, }); }); }); }); ================================================ FILE: e2e/config/playwright-config.ts ================================================ import * as path from 'path'; import { defineConfig } from '@playwright/test'; const ci = !!process.env.CI; const outputDir = path.join(import.meta.dirname, '..', 'e2e', 'test-results'); const testDir = path.join(import.meta.dirname, '..', '..', 'e2e'); // The provisioned github runners are much slower overall than cirrus's, so allow 2 hours for a full e2e run const timeScale = ci ? 4 : 1; const config = defineConfig({ testDir, outputDir, timeout: 10 * 60 * 1000 * timeScale, globalTimeout: 30 * 60 * 1000 * timeScale, workers: 1, reporter: 'list', retries: ci ? 2 : 0, use: { trace: { mode: 'on-all-retries', screenshots: true, }, }, }); export default config; ================================================ FILE: e2e/containers.e2e.spec.ts ================================================ import { expect, test, ElectronApplication, Page } from '@playwright/test'; import { ContainerLogsPage } from './pages/container-logs-page'; import { ContainerShellPage } from './pages/container-shell-page'; import { ContainersPage } from './pages/containers-page'; import { NavPage } from './pages/nav-page'; import { startSlowerDesktop, teardown, tool } from './utils/TestUtils'; import { ContainerEngine } from '@pkg/config/settings'; let page: Page; test.describe.serial('Containers Tests', () => { let electronApp: ElectronApplication; let testContainerId: string; let testContainerName: string; test.beforeAll(async({ colorScheme }, testInfo) => { [electronApp, page] = await startSlowerDesktop(testInfo, { kubernetes: { enabled: false }, containerEngine: { name: ContainerEngine.MOBY, allowedImages: { enabled: false } }, }); const navPage = new NavPage(page); await navPage.progressBecomesReady(); }); test.afterAll(async({ colorScheme }, testInfo) => { if (testContainerId) { try { await tool('docker', 'rm', '-f', testContainerId); } catch (error) {} } await teardown(electronApp, testInfo); }); test('should navigate to containers page', async() => { const navPage = new NavPage(page); const containersPage = await navPage.navigateTo('Containers'); await expect(navPage.mainTitle).toHaveText('Containers'); await containersPage.waitForTableToLoad(); }); test('should create and display test container', async() => { testContainerName = `test-logs-container-${ Date.now() }`; const output = await tool( 'docker', 'run', '--detach', '--name', testContainerName, 'alpine', 'sh', '-c', 'echo "Starting"; for i in $(seq 1 10); do echo "L$i: msg$i"; done; echo "Finished"', ); testContainerId = output.trim(); expect(testContainerId).toMatch(/^[a-f0-9]{64}$/); await page.reload(); const navPage = new NavPage(page); const containersPage = await navPage.navigateTo('Containers'); await containersPage.waitForTableToLoad(); await containersPage.waitForContainerToAppear(testContainerId); await containersPage.viewContainerInfo(testContainerId); await page.waitForURL(`**/containers/info/${ testContainerId }**`, { timeout: 10_000, }); }); test('should display container logs page', async() => { const containerLogsPage = new ContainerLogsPage(page); await expect(containerLogsPage.containerInfo).toBeVisible(); await expect(containerLogsPage.terminal).toBeVisible(); await expect(containerLogsPage.loadingIndicator).not.toBeVisible(); }); test('should show container information', async() => { const containerLogsPage = new ContainerLogsPage(page); await expect(containerLogsPage.containerInfo).toBeVisible(); await expect(containerLogsPage.containerName).toContainText( testContainerName, ); await expect(containerLogsPage.containerState).not.toBeEmpty(); }); test('should display logs content', async() => { const containerLogsPage = new ContainerLogsPage(page); await containerLogsPage.waitForLogsToLoad(); await expect(containerLogsPage.terminal).toContainText('L1: msg1'); }); test('should support log search', async() => { const containerLogsPage = new ContainerLogsPage(page); await expect(containerLogsPage.searchInput).toBeVisible(); const searchTerm = 'msg'; await containerLogsPage.searchLogs(searchTerm); const searchHighlight = page.locator('span.xterm-decoration-top'); await expect(searchHighlight).toBeVisible(); const highlightedRow = containerLogsPage.terminal.locator( '.xterm-rows div', { has: page.locator('.xterm-decoration-top'), }, ); await expect(highlightedRow).toContainText('L1: msg1'); await containerLogsPage.searchNextButton.click(); await expect(searchHighlight).toBeVisible(); await expect(highlightedRow).toContainText('L2: msg2'); await containerLogsPage.searchPrevButton.click(); await expect(searchHighlight).toBeVisible(); await expect(highlightedRow).toContainText('L1: msg1'); await containerLogsPage.searchClearButton.click(); await expect(containerLogsPage.searchInput).toBeEmpty(); await containerLogsPage.terminal.click(); await expect(searchHighlight).not.toBeVisible(); }); test('should handle terminal scrolling', async() => { const scrollTestContainerName = `test-scroll-container-${ Date.now() }`; let scrollTestContainerId: string; try { const output = await tool( 'docker', 'run', '--detach', '--name', scrollTestContainerName, 'alpine', 'sh', '-c', 'for i in $(seq 1 100); do echo "Line $i:"; done; sleep 1', ); scrollTestContainerId = output.trim(); const navPage = new NavPage(page); const containersPage = await navPage.navigateTo('Containers'); await page.reload(); await containersPage.waitForTableToLoad(); await containersPage.waitForContainerToAppear(scrollTestContainerId); await containersPage.viewContainerInfo(scrollTestContainerId); await page.waitForURL(`**/containers/info/${ scrollTestContainerId }**`, { timeout: 10_000, }); const containerLogsPage = new ContainerLogsPage(page); await containerLogsPage.waitForLogsToLoad(); const terminalRows = containerLogsPage.terminal.locator('.xterm-rows'); const lastLine = terminalRows.getByText('Line 100:', { exact: false }); const firstLine = terminalRows.getByText('Line 1:', { exact: false }); await expect(lastLine).toBeVisible(); await expect(firstLine).not.toBeVisible(); await containerLogsPage.scrollToTop(); await expect(firstLine).toBeVisible(); await expect(lastLine).not.toBeVisible(); await containerLogsPage.scrollToBottom(); await expect(lastLine).toBeVisible(); await expect(firstLine).not.toBeVisible(); } finally { if (scrollTestContainerId) { try { await tool('docker', 'rm', '-f', scrollTestContainerId); } catch (cleanupError) {} } } }); test('should output logs if container not exited', async() => { const longRunningContainerName = `test-not-exited-logs-${ Date.now() }`; let longRunningContainerId: string; try { const output = await tool( 'docker', 'run', '--detach', '--name', longRunningContainerName, 'alpine', 'sh', '-c', 'while true; do echo "Log $(date +%s)"; sleep 2; done', ); longRunningContainerId = output.trim(); const navPage = new NavPage(page); const containersPage = await navPage.navigateTo('Containers'); await page.reload(); await containersPage.waitForTableToLoad(); await containersPage.waitForContainerToAppear(longRunningContainerId); await containersPage.viewContainerInfo(longRunningContainerId); await page.waitForURL(`**/containers/info/${ longRunningContainerId }**`, { timeout: 10000, }); const containerLogsPage = new ContainerLogsPage(page); await containerLogsPage.waitForLogsToLoad(); const locator = containerLogsPage.terminal.locator('.xterm-screen'); await expect(locator.getByText(/Log \d+/).nth(1)).toBeVisible(); await expect(containerLogsPage.terminal).toContainText('Log '); await tool('docker', 'rm', '-f', longRunningContainerId); } finally { if (longRunningContainerId) { try { await tool('docker', 'rm', '-f', longRunningContainerId); } catch (cleanupError) {} } } }); test('should auto-refresh containers list', async() => { const containersPage = new ContainersPage(page); const autoRefreshContainerName = `auto-refresh-test-${ Date.now() }`; let autoRefreshContainerId = ''; try { const navPage = new NavPage(page); await navPage.navigateTo('Containers'); await containersPage.waitForTableToLoad(); // Remove all existing containers to ensure clean state try { const existingContainers = await tool('docker', 'ps', '--all', '--quiet'); const containerIds = existingContainers.trim().split(/\s+/); if (containerIds.length > 0) { await tool('docker', 'rm', '--force', ...containerIds); } } catch {} await expect(containersPage.containers).toHaveCount(0); const output = await tool( 'docker', 'run', '--detach', '--name', autoRefreshContainerName, 'alpine', 'sleep', 'infinity', ); autoRefreshContainerId = output.trim(); await expect( containersPage.getContainerRow(autoRefreshContainerId), ).toBeVisible(); await tool('docker', 'rm', '--force', autoRefreshContainerId); await expect( containersPage.getContainerRow(autoRefreshContainerId), ).toBeHidden(); } finally { if (autoRefreshContainerId) { try { await tool('docker', 'rm', '-f', autoRefreshContainerId); } catch {} } } }); }); test.describe.serial('Container Shell Tab', () => { let electronApp: ElectronApplication; let shellContainerId: string; let unsupportedContainerId: string; test.beforeAll(async({ colorScheme }, testInfo) => { [electronApp, page] = await startSlowerDesktop(testInfo, { kubernetes: { enabled: false }, containerEngine: { name: ContainerEngine.MOBY, allowedImages: { enabled: false } }, }); const navPage = new NavPage(page); await navPage.progressBecomesReady(); // Start a long-running container for the shell tests. // Ubuntu is used because the base Alpine image does not include `script` // (util-linux), which is required for the interactive shell session. const output = await tool('docker', 'run', '--detach', 'ubuntu', 'sleep', 'infinity'); shellContainerId = output.trim(); // Alpine container for the "unsupported" test — Alpine's base image has no // `script` command, so the shell tab should show the unsupported banner. const alpineOutput = await tool('docker', 'run', '--detach', 'alpine', 'sleep', 'infinity'); unsupportedContainerId = alpineOutput.trim(); }); test.afterAll(async({ colorScheme }, testInfo) => { if (shellContainerId) { try { await tool('docker', 'rm', '-f', shellContainerId); } catch {} } if (unsupportedContainerId) { try { await tool('docker', 'rm', '-f', unsupportedContainerId); } catch {} } await teardown(electronApp, testInfo); }); async function navigateToShellTab() { const navPage = new NavPage(page); await navPage.navigateTo('Containers'); const containersPage = new ContainersPage(page); await containersPage.waitForTableToLoad(); await containersPage.waitForContainerToAppear(shellContainerId); await containersPage.clickContainerAction(shellContainerId, 'info'); await page.waitForURL(`**/containers/info/${ shellContainerId }**`, { timeout: 10_000 }); const shellPage = new ContainerShellPage(page); await shellPage.clickTab(); await shellPage.waitForTerminal(); await shellPage.waitForShellReady(); return shellPage; } test('should show the Shell tab on a running container', async() => { const shellPage = await navigateToShellTab(); await expect(shellPage.tab).toBeVisible(); await expect(shellPage.terminal).toBeVisible(); await expect(shellPage.notRunningBanner).not.toBeVisible(); }); test('should execute a command and display its output', async() => { const shellPage = new ContainerShellPage(page); // Unique marker avoids false positives from any earlier terminal history. const marker = `RDTEST_${ Date.now() }`; await shellPage.runCommand(`echo ${ marker }`); await shellPage.waitForOutput(marker); }); test('should preserve the session when switching between Logs and Shell tabs', async() => { const shellPage = new ContainerShellPage(page); const logsTab = page.getByTestId('tab-logs'); // A unique marker is required: we must distinguish "this exact output is // still in the buffer" from "the shell printed something similar". const marker = `RDTEST_PERSIST_${ Date.now() }`; await shellPage.runCommand(`echo ${ marker }`); await shellPage.waitForOutput(marker); // Switch to Logs and back. await logsTab.click(); await shellPage.clickTab(); // History must still be visible — session was preserved. await shellPage.waitForOutput(marker); }); test('should show the unsupported banner for containers without script', async() => { // Navigate to the Alpine container — it has no `script`, so the backend // will send container-exec/unsupported instead of starting a session. const navPage = new NavPage(page); await navPage.navigateTo('Containers'); const containersPage = new ContainersPage(page); await containersPage.waitForTableToLoad(); await containersPage.waitForContainerToAppear(unsupportedContainerId); await containersPage.clickContainerAction(unsupportedContainerId, 'info'); await page.waitForURL(`**/containers/info/${ unsupportedContainerId }**`, { timeout: 10_000 }); const shellPage = new ContainerShellPage(page); await shellPage.clickTab(); // The unsupported banner must appear and the terminal must not be rendered. await expect(shellPage.unsupportedBanner).toBeVisible({ timeout: 15_000 }); await expect(shellPage.terminal).not.toBeVisible(); }); }); ================================================ FILE: e2e/credentials-server.e2e.spec.ts ================================================ /* Copyright © 2022 SUSE LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * This file includes end-to-end testing for the HTTP control interface */ import { spawnSync } from 'child_process'; import * as crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; import process from 'process'; import stream from 'stream'; import { findHomeDir } from '@kubernetes/client-node'; import { expect, test } from '@playwright/test'; import { NavPage } from './pages/nav-page'; import { getFullPathForTool, startSlowerDesktop, teardown, tool } from './utils/TestUtils'; import { ServerState } from '@pkg/main/commandServer/httpCommandServer'; import { spawnFile } from '@pkg/utils/childProcess'; import paths from '@pkg/utils/paths'; import type { ElectronApplication, Page } from '@playwright/test'; let credStore = ''; let dockerConfigPath = ''; let originalDockerConfigContents: string | undefined; let plaintextConfigPath = ''; let originalPlaintextConfigContents: string | undefined; interface entryType { ServerURL: string; Username: string; Secret: string; } function makeEntry(url: string, username: string, secret: string): entryType { return { ServerURL: url, Username: username, Secret: secret, }; } /** * This function does multiple-duty: * 1. Skip all the tests if there is no working configured credential helper. * 2. Assign values to the global variables declared after the above `import` statements. * This includes saving the current contents of the docker config files, to be restored at end. */ function haveCredentialServerHelper(): boolean { const homeDir = findHomeDir() ?? '/'; const dockerDir = path.join(homeDir, '.docker'); dockerConfigPath = path.join(dockerDir, 'config.json'); plaintextConfigPath = path.join(dockerDir, 'plaintext-credentials.config.json'); try { originalPlaintextConfigContents = fs.readFileSync(plaintextConfigPath).toString(); } catch { } try { originalDockerConfigContents = fs.readFileSync(dockerConfigPath).toString(); const configObject = JSON.parse(originalDockerConfigContents); credStore = configObject.credsStore; if (!credStore) { credStore = configObject.credsStore = 'none'; fs.writeFileSync(dockerConfigPath, JSON.stringify(configObject, undefined, 2)); } if (credStore === 'none') { return true; } const result = spawnSync(getFullPathForTool(`docker-credential-${ credStore }`), ['list'], { stdio: 'pipe' }); return !result.error; } catch (err: any) { if (err.code === 'ENOENT' && process.env.CI) { try { console.log('Attempting to set up docker-credential-none on CI.'); fs.mkdirSync(dockerDir, { recursive: true }); fs.writeFileSync(dockerConfigPath, JSON.stringify({ credsStore: 'none' }, undefined, 2)); return true; } catch (err2: any) { console.log(`Failed to create a .docker/config.json on the fly for CI: stdout: ${ err2.stdout?.toString() }, stderr: ${ err2.stderr?.toString() }`); } } return false; } } const describeWithCreds = haveCredentialServerHelper() ? test.describe : test.describe.skip; const describeCredHelpers = credStore === 'none' ? test.describe.skip : test.describe; const testUnix = os.platform() === 'win32' ? test.skip : test; describeWithCreds('Credentials server', () => { let electronApp: ElectronApplication; let serverState: ServerState; let authString: string; let page: Page; const curlCommand = os.platform() === 'win32' ? 'curl.exe' : 'curl'; const initialArgs: string[] = []; // Assigned once we have auth string on first use. async function doRequest(path: string, body = '', ignoreStderr = false) { const args = initialArgs.concat([`http://localhost:${ serverState.port }/${ path }`]); if (body.length) { args.push('--data', body); } const { stdout, stderr } = await spawnFile(curlCommand, args, { stdio: 'pipe' }); if (stderr) { if (ignoreStderr) { console.log(`doRequest: spawn ${ curlCommand } ${ args.join(' ') } => ${ stderr }`); } else { expect(stderr).toEqual(''); } } return stdout; } async function doRequestExpectStatus(path: string, body: string, expectedStatus: number) { const args = initialArgs.concat(['-v', `http://localhost:${ serverState.port }/${ path }`]); if (body.length) { args.push('--data', body); } const { stderr } = await spawnFile(curlCommand, args, { stdio: 'pipe' }); expect(stderr).toContain(`HTTP/1.1 ${ expectedStatus }`); } async function addEntry(helper: string, entry: entryType): Promise { const pathToHelper = getFullPathForTool(`docker-credential-${ helper }`); const body = stream.Readable.from(JSON.stringify(entry)); await spawnFile(pathToHelper, ['store'], { stdio: [body, 'pipe', 'pipe'] }); } async function listEntries(helper: string, matcher: string): Promise> { const pathToHelper = getFullPathForTool(`docker-credential-${ helper }`); const { stdout } = await spawnFile(pathToHelper, ['list'], { stdio: ['ignore', 'pipe', 'pipe'] }); const entries: Record = JSON.parse(stdout); for (const k in entries) { if (!k.includes(matcher)) { delete entries[k]; } } return entries; } async function removeEntries(helper: string, matcher: string) { const dcName = `docker-credential-${ helper }`; const stdout = await tool(dcName, 'list'); const servers = Object.keys(JSON.parse(stdout)); let finalException: any | undefined; for (const server of servers) { if (!server.includes(matcher)) { continue; } const body = stream.Readable.from(server); try { const pathToHelper = getFullPathForTool(dcName); const { stdout } = await spawnFile(pathToHelper, ['erase'], { stdio: [body, 'pipe', 'pipe'] }); if (stdout) { const msg = `Problem deleting ${ server } using ${ dcName }: got output stdout: ${ stdout }`; console.log(msg); finalException ??= new Error(msg); } } catch (ex) { console.log(`Problem deleting ${ server } using ${ dcName }: `, ex); finalException ??= ex; } } if (finalException) { throw finalException; } } function rdctlPath() { return getFullPathForTool('rdctl'); } async function rdctlCredWithStdin(command: string, input?: string): Promise<{ stdout: string, stderr: string }> { try { const body = stream.Readable.from(input ?? ''); const args = ['shell', 'sh', '-c', `CREDFWD_CURL_OPTS=--show-error /usr/local/bin/docker-credential-rancher-desktop ${ command }`]; return await spawnFile(rdctlPath(), args, { stdio: [body, 'pipe', 'pipe'] }); } catch (err: any) { throw { stdout: err?.stdout ?? '', stderr: err?.stderr ?? '', error: err, }; } } test.describe.configure({ mode: 'serial' }); test.beforeAll(async({ colorScheme }, testInfo) => { await tool('rdctl', 'reset', '--factory', '--verbose'); [electronApp, page] = await startSlowerDesktop(testInfo, { kubernetes: { enabled: false } }); }); test.afterAll(async() => { if (originalDockerConfigContents !== undefined && !process.env.CI && !process.env.RD_E2E_DO_NOT_RESTORE_CONFIG) { try { await fs.promises.writeFile(dockerConfigPath, originalDockerConfigContents); } catch (e: any) { console.error(`Failed to restore config file ${ dockerConfigPath }: `, e); } } if (originalPlaintextConfigContents !== undefined && !process.env.CI && !process.env.RD_E2E_DO_NOT_RESTORE_CONFIG) { try { await fs.promises.writeFile(plaintextConfigPath, originalPlaintextConfigContents); } catch (e: any) { console.error(`Failed to restore config file ${ plaintextConfigPath }: `, e); } } }); test.afterAll(({ colorScheme }, testInfo) => teardown(electronApp, testInfo)); test('should start loading the background services and hide progress bar', async() => { const navPage = new NavPage(page); await navPage.progressBecomesReady(); await expect(navPage.progressBar).toBeHidden(); }); test('should emit connection information', async() => { const dataPath = path.join(paths.appHome, 'credential-server.json'); const dataRaw = await fs.promises.readFile(dataPath, 'utf-8'); serverState = JSON.parse(dataRaw); expect(serverState).toEqual(expect.objectContaining({ user: expect.any(String), password: expect.any(String), port: expect.any(Number), pid: expect.any(Number), })); // Check if the process is running. try { expect(process.kill(serverState.pid, 0)).toBeTruthy(); } catch (ex: any) { // Exception here is acceptable, if the error is due to EPERM. expect(ex).toHaveProperty('code', 'EPERM'); } // Now is a good time to initialize the various connection-related values. authString = `${ serverState.user }:${ serverState.password }`; // common arguments for curl initialArgs.push('--silent', '--user', authString, '--request', 'POST'); }); test('should require authentication', async() => { const url = `http://127.0.0.1:${ serverState.port }/list`; const resp = await fetch(url); expect(resp.ok).toBeFalsy(); expect(resp.status).toEqual(401); }); test('should be able to use the API', async() => { const bobsURL = 'https://bobs.fish/tackle'; const bobsFirstSecret = 'loblaw'; const bobsSecondSecret = 'shoppers with spaces and % and \' and &s'; const body = { ServerURL: bobsURL, Username: 'bob', Secret: bobsFirstSecret, }; let stdout: string = await doRequest('list'); if (JSON.parse(stdout)[bobsURL]) { await doRequestExpectStatus('erase', bobsURL, 200); } await doRequestExpectStatus('store', JSON.stringify(body), 200); stdout = await doRequest('list'); expect(JSON.parse(stdout)).toMatchObject({ [bobsURL]: 'bob' }); stdout = await doRequest('get', bobsURL); expect(JSON.parse(stdout)).toMatchObject(body); // Verify we can store and retrieve passwords with wacky characters in them. body.Secret = bobsSecondSecret; await doRequestExpectStatus('store', JSON.stringify(body), 200); stdout = await doRequest('get', bobsURL); expect(JSON.parse(stdout)).toMatchObject(body); await doRequestExpectStatus('erase', bobsURL, 200); // Instead of returning an error message, // `docker-credential-pass` will happily return an object with `ServerURL` set to the provided argument, // and empty strings for Username and Secret. // This is a bit crazy, because `pass show noSuchEntry` gives an error message. // Upstream error: https://github.com/docker/docker-credential-helpers/issues/220 if (credStore !== 'pass') { stdout = await doRequest('get', bobsURL); expect(stdout).toContain('credentials not found in native keychain'); } // Don't bother trying to test erasing a nonexistent credential, because the // behavior is all over the place. Fails with osxkeychain, succeeds with wincred. }); test('it should complain about an unrecognized command', async() => { const badCommand = 'gazornaanplatt'; const stdout = await doRequest(badCommand); expect(stdout).toContain(`Unknown credential action '${ badCommand }' for the credential-server, must be one of [erase|get|list|store]`); }); test('it should complain about non-POST requests', async() => { const args = initialArgs.concat([`http://localhost:${ serverState.port }/list`]); const postIndex = args.indexOf('POST'); if (postIndex > -1) { args.splice(postIndex - 1, 2); } await expect(spawnFile(curlCommand, args, { stdio: 'pipe' })).resolves.toMatchObject({ stdout: expect.stringContaining('Expecting a POST method for the credential-server list request, received GET'), stderr: '', }); }); test('should be able to use the script', async() => { const bobsURL = 'https://bobs.fish/tackle'; const bobsFirstSecret = 'loblaw'; const bobsSecondSecret = 'shoppers with spaces and % and \' and &s and even a 😱'; const body = { ServerURL: bobsURL, Username: 'bob', Secret: bobsFirstSecret, }; let { stdout } = await rdctlCredWithStdin('list'); if (stdout && JSON.parse(stdout)[bobsURL]) { ({ stdout } = await rdctlCredWithStdin('erase', bobsURL)); expect(stdout).toEqual(''); } await expect(rdctlCredWithStdin('store', JSON.stringify(body))).resolves.toMatchObject({ stdout: '' }); ({ stdout } = await rdctlCredWithStdin('list')); expect(JSON.parse(stdout)).toMatchObject({ [bobsURL]: 'bob' }); ({ stdout } = await rdctlCredWithStdin('get', bobsURL)); expect(JSON.parse(stdout)).toMatchObject(body); // Verify we can store and retrieve passwords with wacky characters in them. body.Secret = bobsSecondSecret; await expect(rdctlCredWithStdin('store', JSON.stringify(body))).resolves.toMatchObject({ stdout: '' }); ({ stdout } = await rdctlCredWithStdin('get', bobsURL)); expect(JSON.parse(stdout)).toMatchObject(body); if (credStore !== 'pass') { // See above comment discussing the consequences of `echo ARG | docker-credential-pass get` never failing. await expect(rdctlCredWithStdin('erase', bobsURL)).resolves.toMatchObject({ stdout: '' }); await expect(rdctlCredWithStdin('get', bobsURL)).rejects.toMatchObject({ stdout: expect.stringContaining('credentials not found in native keychain'), stderr: expect.stringContaining('Error: exit status 22'), }); } }); // This test currently fails on Windows due to https://github.com/docker/docker-credential-helpers/issues/190 testUnix('complains when the limit is exceeded (on the server - do an inexact check)', async() => { const args = [ 'shell', 'sh', '-c', `export CREDFWD_CURL_OPTS="--show-error"; \ SECRET=$(tr -dc 'A-Za-z0-9,._=' < /dev/urandom | head -c5242880); \ echo '{"ServerURL":"https://example.com/v1","Username":"alice","Secret":"'$SECRET'"}' | /usr/local/bin/docker-credential-rancher-desktop store`, ]; try { // This should throw, but we care about more than one error field, so use a try-catch const { stdout } = await spawnFile(rdctlPath(), args, { stdio: ['ignore', 'pipe', 'pipe'] }); expect(stdout).toEqual('should have failed'); } catch (err: any) { expect(err).toMatchObject({ stdout: expect.stringContaining('request body is too long, request body size exceeds 4194304'), stderr: expect.stringContaining('The requested URL returned error: 413\nError: exit status 22'), }); } }); // This test currently fails on Windows due to https://github.com/docker/docker-credential-helpers/issues/190 testUnix('handles long, legal payloads that can be verified', async() => { const calsURL = 'https://cals.nightcrawlers.com/guaranteed'; const keyLength = 5000; const secret = crypto.randomBytes(keyLength / 2).toString('hex'); const args = [ 'shell', 'sh', '-c', `echo '{"ServerURL":"${ calsURL }","Username":"cal","Secret":"${ secret }"}' | /usr/local/bin/docker-credential-rancher-desktop store`, ]; await expect(spawnFile(rdctlPath(), args, { stdio: ['ignore', 'pipe', 'pipe'] })).resolves.toBeDefined(); const { stdout } = await rdctlCredWithStdin('get', calsURL); expect(JSON.parse(stdout).Secret).toEqual(secret); }); test.describe('should be able to detect errors', () => { const bobsURL = 'https://bobs.fish/bait'; test('it should complain when no ServerURL is given', async() => { const body: Record = {}; await expect(rdctlCredWithStdin('store', JSON.stringify(body))).rejects.toMatchObject({ stdout: expect.stringContaining('no credentials server URL'), stderr: expect.stringContaining('Error: exit status 22'), }); }); test('it should complain when no username is given', async() => { const body: Record = { ServerURL: bobsURL }; await expect(rdctlCredWithStdin('store', JSON.stringify(body))).rejects.toMatchObject({ stdout: expect.stringContaining('no credentials username'), stderr: expect.stringContaining('Error: exit status 22'), }); }); test('it should not complain about extra fields', async() => { const body: Record = { ServerURL: bobsURL, Username: 'bob', Soup: 'gazpacho', }; await expect(rdctlCredWithStdin('store', JSON.stringify(body))).resolves.toMatchObject({ stdout: '' }); const { stdout, stderr } = await rdctlCredWithStdin('get', bobsURL); expect({ stdout: JSON.parse(stdout), stderr }).toMatchObject({ // Playwright type definitions for `expect.not` is missing; see // playwright issue #15087. stdout: (expect as any).not.objectContaining({ Soup: 'gazpacho' }), }); }); }); // Skip these tests if config.credsStore and the credHelpers are both using 'none' describeCredHelpers('handles credHelpers', () => { const peopleEntries: Record = { bob: makeEntry('https://bobs.fish/clams01', 'Bob', 'clams01'), carol: makeEntry('https://bobs.fish/clams02', 'Carol', 'clams02'), ted: makeEntry('https://bobs.fish/clams03', 'Ted', 'clams03'), alice: makeEntry('https://bobs.fish/clams04', 'Alice', 'clams04'), fakeTed: makeEntry('https://bobs.fish/clams03', 'Fake-Ted', 'Fake-clams03'), }; const dockerConfig = { auths: {}, credsStore: '', currentContext: 'rancher-desktop', credHelpers: { 'https://bobs.fish/clams03': 'none', 'https://bobs.fish/clams05': 'none', }, }; let existingDockerConfig: Buffer | undefined; test.beforeAll(async() => { const platform = os.platform(); if (platform.startsWith('win')) { dockerConfig.credsStore = 'wincred'; } else if (platform === 'darwin') { dockerConfig.credsStore = 'osxkeychain'; } else if (platform === 'linux') { dockerConfig.credsStore = 'pass'; } else { throw new Error(`Unexpected platform of ${ platform }`); } try { existingDockerConfig = await fs.promises.readFile(dockerConfigPath); } catch (ex) { if (Object(ex).code !== 'ENOENT') { throw ex; } } await fs.promises.writeFile(dockerConfigPath, JSON.stringify(dockerConfig, undefined, 2)); }); test.afterAll(async() => { if (existingDockerConfig) { await fs.promises.writeFile(dockerConfigPath, existingDockerConfig); } else { await fs.promises.unlink(dockerConfigPath); } }); // removeEntries and addEntry return Promise, // so the best we can do is assert that they don't throw an exception. test.beforeEach(async() => { await expect(removeEntries(dockerConfig.credsStore, 'https://bobs.fish/clams')).resolves.not.toThrow(); await expect(removeEntries('none', 'https://bobs.fish/clams')).resolves.not.toThrow(); }); test('reading prepopulated entries through d-c-rd', async() => { await expect(addEntry(dockerConfig.credsStore, peopleEntries.bob)).resolves.not.toThrow(); await expect(addEntry(dockerConfig.credsStore, peopleEntries.carol)).resolves.not.toThrow(); await expect(addEntry('none', peopleEntries.ted)).resolves.not.toThrow(); // These two should not be found await expect(addEntry('none', peopleEntries.alice)).resolves.not.toThrow(); await expect(addEntry(dockerConfig.credsStore, peopleEntries.fakeTed)).resolves.not.toThrow(); // Now verify that `rdctl dcrd list` gives 01 ... 03 but not Fake-Ted 03, and not 04 because it's not discoverable. const entries = JSON.parse(await doRequest('list')); expect(entries).toMatchObject({ [peopleEntries.bob.ServerURL]: peopleEntries.bob.Username, [peopleEntries.carol.ServerURL]: peopleEntries.carol.Username, [peopleEntries.ted.ServerURL]: peopleEntries.ted.Username, }); expect(entries).not.toMatchObject({ [peopleEntries.alice.ServerURL]: peopleEntries.alice.Username, [peopleEntries.fakeTed.ServerURL]: peopleEntries.fakeTed.Username, }); // Then verify we can dcrd-get clams01, 02, and 03, but not 04 or 05 for (const name of ['bob', 'carol', 'ted']) { const record = JSON.parse(await doRequest('get', peopleEntries[name].ServerURL)); expect(record).toMatchObject(peopleEntries[name] as unknown as Record); } if (dockerConfig.credsStore !== 'pass') { // TODO: Stop testing for pass once we bring in d-c-pass 0.7.0 or higher await expect(doRequest('get', peopleEntries.alice.ServerURL, true)) .resolves.toEqual('credentials not found in native keychain\n'); await expect(doRequest('get', 'https://bobs.fish/clams05', true)) .resolves.toEqual('credentials not found in native keychain\n'); } // Then dcrd-delete all of them, and verify that dcrd-list is empty. // But use lower-level dc helpers to show that clams04 and Fake-Ted clams03 are still around, // and then delete them. await expect(doRequest('erase', peopleEntries.bob.ServerURL)).resolves.toEqual(''); await expect(doRequest('erase', peopleEntries.carol.ServerURL)).resolves.toEqual(''); await expect(doRequest('erase', peopleEntries.ted.ServerURL)).resolves.toEqual(''); // Looks like different credential-helpers handle missing erase arguments differently, so don't check results await doRequest('erase', peopleEntries.alice.ServerURL); await doRequest('erase', peopleEntries.fakeTed.ServerURL); const postDeleteEntries = JSON.parse(await doRequest('list')); expect(postDeleteEntries).not.toMatchObject({ [peopleEntries.bob.ServerURL]: peopleEntries.bob.Username, [peopleEntries.carol.ServerURL]: peopleEntries.carol.Username, [peopleEntries.ted.ServerURL]: peopleEntries.ted.Username, [peopleEntries.alice.ServerURL]: peopleEntries.alice.Username, }); await expect(listEntries(dockerConfig.credsStore, 'https://bobs.fish/clams')).resolves .toMatchObject({ [peopleEntries.fakeTed.ServerURL]: peopleEntries.fakeTed.Username }); await expect(listEntries('none', 'https://bobs.fish/clams')).resolves .toMatchObject({ [peopleEntries.alice.ServerURL]: peopleEntries.alice.Username }); }); test('dcrd store uses credHelpers', async() => { // Use dcrd-store to store clams 01 ... 04, and show that they ended up where expected. // This is the inverse of the previous test. await doRequestExpectStatus('store', JSON.stringify(peopleEntries.bob), 200); await doRequestExpectStatus('store', JSON.stringify(peopleEntries.carol), 200); await doRequestExpectStatus('store', JSON.stringify(peopleEntries.ted), 200); await doRequestExpectStatus('store', JSON.stringify(peopleEntries.alice), 200); await expect(listEntries(dockerConfig.credsStore, 'https://bobs.fish/clams')).resolves.toMatchObject({ [peopleEntries.bob.ServerURL]: peopleEntries.bob.Username, [peopleEntries.carol.ServerURL]: peopleEntries.carol.Username, [peopleEntries.alice.ServerURL]: peopleEntries.alice.Username, }); await expect(listEntries(dockerConfig.credsStore, 'https://bobs.fish/clams')) .resolves.not.toMatchObject({ [peopleEntries.ted.ServerURL]: peopleEntries.ted.Username }); await expect(listEntries('none', 'https://bobs.fish/clams')).resolves .toMatchObject({ [peopleEntries.ted.ServerURL]: peopleEntries.ted.Username }); await expect(listEntries('none', 'https://bobs.fish/clams')) .resolves.not.toMatchObject({ [peopleEntries.bob.ServerURL]: peopleEntries.bob.Username, [peopleEntries.carol.ServerURL]: peopleEntries.carol.Username, [peopleEntries.alice.ServerURL]: peopleEntries.alice.Username, }); }); }); }); ================================================ FILE: e2e/extensions.e2e.spec.ts ================================================ /* * This tests interactions with the extension front end. * An E2E test is required to have access to the web page context. */ import os from 'os'; import path from 'path'; import { ElectronApplication, Page, test, expect, JSHandle, TestInfo, } from '@playwright/test'; import { NavPage } from './pages/nav-page'; import { getFullPathForTool, getResourceBinDir, reportAsset, retry, startSlowerDesktop, teardown, } from './utils/TestUtils'; import { ContainerEngine, Settings } from '@pkg/config/settings'; import { spawnFile } from '@pkg/utils/childProcess'; import { Log } from '@pkg/utils/logging'; import type { BrowserWindow, WebContentsView } from 'electron'; /** The top level source directory, assuming we're always running from the tree */ const srcDir = path.dirname(import.meta.dirname); const rdctl = getFullPathForTool('rdctl'); // On Windows there's an eval routine that treats backslashes as escape-sequence leaders, // so it's better to replace them with forward slashes. The file can still be found, // and we don't have to deal with unintended escape-sequence processing. const execPath = process.execPath.replace(/\\/g, '/'); let console: Log; const NAMESPACE = 'rancher-desktop-extensions'; test.describe.serial('Extensions', () => { let app: ElectronApplication; let page: Page; let isContainerd = false; async function ctrctl(...args: string[]) { let tool = getFullPathForTool('nerdctl'); if (isContainerd) { args = ['--namespace', NAMESPACE].concat(args); } else { tool = getFullPathForTool('docker'); if (process.platform !== 'win32') { args = ['--context', 'rancher-desktop'].concat(args); } } return await spawnFile(tool, args, { stdio: 'pipe', env: { ...process.env, PATH: `${ process.env.PATH }${ path.delimiter }${ getResourceBinDir() }`, }, }); } test.beforeAll(async({ colorScheme }, testInfo) => { [app, page] = await startSlowerDesktop(testInfo, { containerEngine: { name: ContainerEngine.MOBY }, kubernetes: { enabled: false }, }); console = new Log(path.basename(import.meta.filename, '.ts'), reportAsset(testInfo, 'log')); }); test.afterAll(({ colorScheme }, testInfo) => teardown(app, testInfo)); // Set things up so console messages from the UI gets logged too. let currentTestInfo: TestInfo; test.beforeEach(({ browserName }, testInfo) => { currentTestInfo = testInfo; }); test.beforeAll(() => { page.on('console', (message) => { console.error(`${ currentTestInfo.titlePath.join(' >> ') } >> ${ message.text() }`); }); }); test('should load backend', async() => { await (new NavPage(page)).progressBecomesReady(); }); test('determine container engine in use', async() => { const { stdout } = await spawnFile(rdctl, ['list-settings'], { stdio: 'pipe' }); const settings: Settings = JSON.parse(stdout); expect(settings.containerEngine.name).toMatch(/^(?:containerd|moby)$/); isContainerd = settings.containerEngine.name === ContainerEngine.CONTAINERD; }); test('wait for buildkit', async() => { test.skip(!isContainerd, 'Not running containerd, no need to wait for buildkit'); // `buildctl debug info` talks to the backend (to fetch info about it), so // if it succeeds it means the backend is up and can respond to requests. await retry(() => spawnFile(rdctl, ['shell', 'buildctl', 'debug', 'info'])); }); test('wait for docker context', async() => { test.skip(isContainerd, 'Not running moby, no need to wait for context'); test.skip(process.platform === 'win32', 'Not setting context on Windows'); await retry(() => ctrctl('context', 'inspect', 'rancher-desktop')); }); test('wait for docker daemon to be up', async() => { test.skip(isContainerd, 'Not running moby, no need to wait for context'); // On Windows, the docker proxy can flap for a while. So we try a few times // in a row (with pauses in the middle) to ensure the backend is stable // before continuing. for (let i = 0; i < 10; ++i) { await retry(() => ctrctl('system', 'info')); await new Promise(resolve => setTimeout(resolve, 1_000)); } }); test('build and install testing extension', async() => { const dataDir = path.join(srcDir, 'bats', 'tests', 'extensions', 'testdata'); try { await ctrctl('build', '--tag', 'rd/extension/everything', '--build-arg', 'variant=everything', dataDir); await spawnFile(rdctl, ['api', '-XPOST', '/v1/extensions/install?id=rd/extension/everything']); } catch (ex) { console.error(ex); throw ex; } }); test('use extension protocol handler', async() => { const result = await page.evaluate(async() => { const data = await fetch('x-rd-extension://72642f657874656e73696f6e2f65766572797468696e67/ui/dashboard-tab/ui/index.html'); return await data.text(); }); expect(result).toContain('ddClient'); }); test.describe('extension API', () => { let view: JSHandle; test('extension UI can be loaded', async() => { const window: JSHandle = await app.browserWindow(page); await page.click('.nav .nav-item[data-id="extension:rd/extension/everything"]'); // Try until we can get a BrowserView for the extension (because it can // take some time to load). view = await retry(async() => { // Evaluate script remotely to look for the appropriate BrowserView const result = await window.evaluateHandle((window: BrowserWindow) => { for (const view of window.contentView.children) { // Because this runs in the remote window, we don't have access to // imports, and therefore types; hence we can't do an `instanceof` // check here. if ('webContents' in view) { if ((view as any).webContents.mainFrame.url.startsWith('x-rd-extension://')) { return view; } } } }) as JSHandle; // Check that the result evaluated to the view, and not undefined. if (await (result).evaluate(v => typeof v) === 'undefined') { throw new Error('Could not find extension view'); } return result as JSHandle; }); await view.evaluate((v, { window }) => { v.webContents.addListener('console-message', (event) => { const { message, level, lineNumber, sourceId, } = event; const outputMessage = `[${ level }] ${ message } @${ sourceId }:${ lineNumber }`; window.webContents.executeJavaScript(`console.log(${ JSON.stringify(outputMessage) })`); }); }, { window }); }); /** evaluate a short snippet in the extension context. */ async function evalInView(script: string): Promise { // view.evaluate doesn't pass rejections correctly; instead, we convert it // to a resolved object, and convert it back to a rejection on the other end. const { rejected, result } = await view.evaluate(async(v, { script }) => { try { return { rejected: false, result: await v.webContents.executeJavaScript(script) }; } catch (ex) { return { rejected: true, result: ex }; } }, { script }); if (rejected) { throw result; } return result; } test('exposes API endpoint', async() => { const result = { platform: await evalInView('ddClient.host.platform'), arch: await evalInView('ddClient.host.arch'), hostname: await evalInView('ddClient.host.hostname'), }; expect(result).toEqual({ platform: os.platform(), arch: os.arch(), hostname: os.hostname(), }); }); test.describe('ddClient.extension.host.cli.exec', () => { const wrapperName = process.platform === 'win32' ? 'dummy.exe' : 'dummy.sh'; test('capturing output', async() => { const script = ` ddClient.extension.host.cli.exec("${ wrapperName }", [ "${ execPath }", "-e", "console.log(1 + 1)" ]).then(({cmd, killed, signal, code, stdout, stderr}) => ({ /* Rebuild the object so it can be serialized properly */ cmd, killed, signal, code, stdout, stderr })); `; const result = await evalInView(script); expect(result).toEqual(expect.objectContaining({ cmd: expect.stringContaining(wrapperName), code: 0, stdout: expect.stringContaining('2'), stderr: expect.stringContaining(''), })); }); test('streaming output', async() => { const script = ` (new Promise((resolve) => { let output = [], errors = [], exitCodes = []; ddClient.extension.host.cli.exec("${ wrapperName }", [ "${ execPath }", "-e", "console.log(2 + 2); console.error(3 + 3);"], { stream: { onOutput: (data) => { output.push(data); }, onError: (err) => { errors.push(err); resolve(output, errors, exitCodes); }, onClose: (exitCode) => { exitCodes.push(exitCode); resolve({output, errors, exitCodes}); }, } }); })).catch(ex => ex); `; const result = await evalInView(script); expect(result).toEqual(expect.objectContaining({ output: expect.arrayContaining([ { stdout: expect.stringContaining('4') }, { stderr: expect.stringContaining('6') }, ]), errors: [], exitCodes: [0], })); }); test('bypass CORS', async() => { // This is the dashboard URL; it does not have CORS set up so it would // normally fail to fetch due to CORS reasons. However, this test case // checks that our CORS bypass is working. const url = 'http://127.0.0.1:6120/c/local/explorer/node'; const script = ` (async () => { const result = await fetch('${ url }'); return Object.fromEntries(result.headers.entries()); })() `; const result = await evalInView(script); expect(result).toHaveProperty('content-type'); }); }); test.describe('ddClient.docker', () => { test('ddClient.docker.cli.exec', async() => { const script = ` ddClient.docker.cli.exec("info", ["--format", "{{ json . }}"]) .then(v => v.parseJsonObject()) .then(j => JSON.stringify(j)); `; const result = JSON.parse(await evalInView(script)); expect(result).toEqual(expect.objectContaining({ ID: expect.any(String), Driver: expect.any(String), Plugins: expect.objectContaining({}), MemoryLimit: expect.any(Boolean), SwapLimit: expect.any(Boolean), MemTotal: expect.any(Number), OSType: 'linux', })); }); test('ddClient.docker.listImages', async() => { const options = { digests: true, namespace: isContainerd ? NAMESPACE : undefined, }; const script = `ddClient.docker.listImages(${ JSON.stringify(options) })`; const result = await evalInView(script); expect(result).toEqual(expect.arrayContaining([ expect.objectContaining({ Id: expect.any(String), ParentId: expect.any(String), RepoTags: expect.arrayContaining(['rd/extension/everything:latest']), Created: expect.any(Number), Size: expect.any(Number), SharedSize: expect.any(Number), VirtualSize: expect.anything(), Labels: expect.any(Object), Containers: expect.any(Number), }), ])); }); test('ddClient.docker.listContainers', async() => { const options = { size: !isContainerd, // nerdctl doesn't implement --size namespace: isContainerd ? NAMESPACE : undefined, }; const script = `ddClient.docker.listContainers(${ JSON.stringify(options) })`; const result = await evalInView(script); const container = result.find((r: { Image: string; }) => r.Image.startsWith('rd/extension/everything')); // The playwright copy of expect() produces terrible error messages when // things don't match, making it difficult to find what was wrong. // Match properties individually to make things easier to spot. expect(container).toBeTruthy(); expect(container).toHaveProperty('Id', expect.any(String)); expect(container).toHaveProperty('Names', expect.arrayContaining([expect.any(String)])); expect(container).toHaveProperty('Image', expect.stringContaining('rd/extension/everything')); expect(container).toHaveProperty('ImageID', expect.any(String)); expect(container).toHaveProperty('Command', expect.any(String)); expect(container).toHaveProperty('Created', expect.any(Number)); expect(container).toHaveProperty('Ports', expect.anything()); expect(container).toHaveProperty('SizeRw', expect.any(Number)); expect(container).toHaveProperty('SizeRootFs', expect.any(Number)); expect(container).toHaveProperty('Labels', expect.any(Object)); expect(container).toHaveProperty('State', expect.any(String)); expect(container).toHaveProperty('Status', expect.any(String)); expect(container).toHaveProperty('HostConfig', expect.any(Object)); expect(container).toHaveProperty('NetworkSettings', expect.any(Object)); expect(container).toHaveProperty('Mounts', expect.any(Array)); }); }); test.describe('ddClient.extension.vm.cli.exec', () => { test('capturing output', async() => { // `.exec()` returns an object that has methods, which cannot be passed // via `webContents.executeJavaScript`; serialize it as JSON and // deserialize instead. const result = evalInView(` ddClient.extension.vm.cli.exec("/bin/echo", ["xyzzy"]) .then(v => JSON.parse(JSON.stringify(v))) `); await expect(result).resolves.toMatchObject({ stdout: 'xyzzy\n', code: 0, }); }); }); test.describe('ddClient.extension.host.cli.exec', () => { test('reject when command is not found', async() => { // Errors cannot be round-tripped correctly. const result = evalInView(` ddClient.extension.host.cli.exec('command-not-found', []) .catch(v => Promise.reject(v instanceof Error ? v.toString() : JSON.stringify(v))) `); await expect(result).rejects.toMatch(/ENOENT|The system cannot find the file specified/); }); test('reject when command fails', async() => { const command = process.platform === 'win32' ? 'dummy.exe' : 'dummy.sh'; // The returned exception has methods, which cannot be cloned across // evalInView; we serialize it as JSON and deserialize again to remove them. const result = evalInView(` ddClient.extension.host.cli.exec("${ command }", ["false"]) .then(v => Promise.resolve(JSON.parse(JSON.stringify(v)))) .catch(v => Promise.reject(JSON.parse(JSON.stringify(v)))) `); await expect(result).rejects.toMatchObject({ code: 1, cmd: expect.stringMatching(/dummy.*false/), }); }); }); test.describe('ddClient.extension.vm.service', () => { test('can fetch from the backend', async() => { const url = '/get/etc/os-release'; await retry(async() => { const result = evalInView(`ddClient.extension.vm.service.get("${ url }")`); await expect(result).resolves.toContain('VERSION_ID'); }); }); test('can fetch from external sources', async() => { const url = 'http://127.0.0.1:6120/c/local/explorer/node'; // dashboard await retry(async() => { const result = evalInView(`ddClient.extension.vm.service.get("${ url }")`); await expect(result).resolves.toContain('Rancher'); }); }); test.describe('can post values', () => { test('with string body', async() => { await retry(async() => { const result = evalInView(`ddClient.extension.vm.service.post("/post", "hello")`); await expect(result).resolves.toMatchObject({ headers: { 'Content-Type': expect.arrayContaining([expect.stringMatching(/^text\/plain\b/)]) }, body: 'hello', }); }); }); test('with JSON body', async() => { await retry(async() => { const result = evalInView(`ddClient.extension.vm.service.post("/post", {foo: 'bar'})`); await expect(result).resolves.toMatchObject({ headers: { 'Content-Type': expect.arrayContaining([expect.stringMatching(/^application\/json\b/)]) }, body: JSON.stringify({ foo: 'bar' }), }); }); }); test.describe('throws with error status', () => { for (const statusCode of [400, 401, 403, 404, 451, 500, 503, 504]) { for (const method of ['get', 'post']) { test(`${ method } ${ statusCode }`, async() => { const result = evalInView(`ddClient.extension.vm.service.${ method }("/status/${ statusCode }", {})`); await expect(result).rejects.toMatchObject({ statusCode, message: expect.stringContaining(`${ statusCode }`), }); }); } } }); }); }); }); }); ================================================ FILE: e2e/lockedFields.e2e.spec.ts ================================================ /* Copyright © 2023 SUSE LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Integration tests that verify that the deployment profile reader is finding locked fields, * and that rdctl can't change those locked preferences. */ import os from 'os'; import path from 'path'; import { expect, test } from '@playwright/test'; import { NavPage } from './pages/nav-page'; import { verifyNoSystemProfile } from './utils/ProfileUtils'; import { createDefaultSettings, setUserProfile, startRancherDesktop, teardown, tool, } from './utils/TestUtils'; import type { DeploymentProfileType } from '@pkg/config/settings'; import { CURRENT_SETTINGS_VERSION } from '@pkg/config/settings'; import { readDeploymentProfiles } from '@pkg/main/deploymentProfiles'; import { spawnFile } from '@pkg/utils/childProcess'; import { reopenLogs } from '@pkg/utils/logging'; import type { ElectronApplication, Page } from '@playwright/test'; test.describe('Locked fields', () => { let electronApp: ElectronApplication; let page: Page; const appPath = path.dirname(import.meta.dirname); let deploymentProfile: DeploymentProfileType | null = null; function rdctlPath() { return path.join(appPath, 'resources', os.platform(), 'bin', os.platform() === 'win32' ? 'rdctl.exe' : 'rdctl'); } async function rdctl(commandArgs: string[]): Promise< { stdout: string, stderr: string, error?: any }> { try { return await spawnFile(rdctlPath(), commandArgs, { stdio: 'pipe' }); } catch (err: any) { return { stdout: err?.stdout ?? '', stderr: err?.stderr ?? '', error: err, }; } } async function saveUserProfile() { // Ignore any errors in the existing profile, but it means they won't be saved. try { deploymentProfile = await readDeploymentProfiles(); } catch { } } async function restoreUserProfile() { await setUserProfile(deploymentProfile?.defaults ?? null, deploymentProfile?.locked ?? null); } test.describe.configure({ mode: 'serial' }); const lockedK8sVersion = '1.26.3'; const proposedK8sVersion = '1.26.1'; test.beforeAll(async() => { await tool('rdctl', 'reset', '--factory', '--verbose'); reopenLogs(); }); test.afterAll(async() => { await restoreUserProfile(); }); test.beforeAll(async({ colorScheme }, testInfo) => { createDefaultSettings({ containerEngine: { allowedImages: { enabled: true, patterns: ['a', 'b', 'c', 'e'] } } }); await saveUserProfile(); await setUserProfile( { version: 10 as typeof CURRENT_SETTINGS_VERSION, containerEngine: { allowedImages: { enabled: true } } }, { version: 10, containerEngine: { allowedImages: { enabled: true, patterns: ['c', 'd', 'f'] } }, kubernetes: { version: lockedK8sVersion }, }, ); electronApp = await startRancherDesktop(testInfo); page = await electronApp.firstWindow(); }); test.afterAll(async({ colorScheme }, testInfo) => { await teardown(electronApp, testInfo); await tool('rdctl', 'reset', '--factory', '--verbose'); reopenLogs(); }); test('should start up', async() => { const navPage = new NavPage(page); await navPage.progressBecomesReady(); await expect(navPage.progressBar).toBeHidden(); }); test('should not allow a locked field to be changed via rdctl set', async() => { const { stdout, stderr, error } = await rdctl(['list-settings']); const skipReasons = await verifyNoSystemProfile(); test.skip(skipReasons.length > 0, skipReasons.join('\n')); expect({ stderr, error }).toEqual({ error: undefined, stderr: '' }); const originalSettings = JSON.parse(stdout); const newEnabled = !originalSettings.containerEngine.allowedImages.enabled; expect(originalSettings.containerEngine.allowedImages.patterns.sort()).toEqual(['c', 'd', 'f']); await expect(rdctl(['set', `--container-engine.allowed-images.enabled=${ newEnabled }`])) .resolves.toMatchObject({ stdout: '', stderr: expect.stringContaining(`field "containerEngine.allowedImages.enabled" is locked`), }); await expect(rdctl(['set', `--kubernetes.version=${ proposedK8sVersion }`])) .resolves.toMatchObject({ stdout: '', stderr: expect.stringContaining(`field "kubernetes.version" is locked`), }); await expect(rdctl(['set', `--kubernetes.version=${ lockedK8sVersion }`])) .resolves.toMatchObject({ stdout: expect.stringContaining('Status: no changes necessary.'), stderr: '', }); }); }); ================================================ FILE: e2e/main.e2e.spec.ts ================================================ import { test, expect, _electron } from '@playwright/test'; import { NavPage } from './pages/nav-page'; import { createDefaultSettings, startRancherDesktop, teardown } from './utils/TestUtils'; import type { ElectronApplication, Page } from '@playwright/test'; let page: Page; /** * Using test.describe.serial make the test execute step by step, as described on each `test()` order * Playwright executes test in parallel by default and it will not work for our app backend loading process. * */ test.describe.serial('Main App Test', () => { let electronApp: ElectronApplication; test.beforeAll(async({ colorScheme }, testInfo) => { createDefaultSettings(); electronApp = await startRancherDesktop(testInfo); page = await electronApp.firstWindow(); }); test.afterAll(({ colorScheme }, testInfo) => teardown(electronApp, testInfo)); test('should start loading the background services and hide progress bar', async() => { const navPage = new NavPage(page); await navPage.progressBecomesReady(); await expect(navPage.progressBar).toBeHidden(); }); test('should land on General page', async() => { const navPage = new NavPage(page); await expect(navPage.mainTitle).toHaveText('Welcome to Rancher Desktop by SUSE'); }); test('should navigate to Port Forwarding and check elements', async() => { const navPage = new NavPage(page); const portForwardPage = await navPage.navigateTo('PortForwarding'); await expect(navPage.mainTitle).toHaveText('Port Forwarding'); await expect(portForwardPage.content).toBeVisible(); await expect(portForwardPage.table).toBeVisible(); await expect(portForwardPage.fixedHeader).toBeVisible(); }); test('should navigate to Images page', async() => { const navPage = new NavPage(page); const imagesPage = await navPage.navigateTo('Images'); await expect(navPage.mainTitle).toHaveText('Images'); await expect(imagesPage.table).toBeVisible(); }); test('should navigate to Troubleshooting and check elements', async() => { const navPage = new NavPage(page); const troubleshootingPage = await navPage.navigateTo('Troubleshooting'); await expect(navPage.mainTitle).toHaveText('Troubleshooting'); await expect(troubleshootingPage.troubleshooting).toBeVisible(); await expect(troubleshootingPage.logsButton).toBeVisible(); await expect(troubleshootingPage.factoryResetButton).toBeVisible(); }); }); ================================================ FILE: e2e/pages/container-logs-page.ts ================================================ import { expect } from '@playwright/test'; import type { Locator, Page } from '@playwright/test'; export class ContainerLogsPage { readonly page: Page; readonly terminal: Locator; readonly containerInfo: Locator; readonly containerName: Locator; readonly containerState: Locator; readonly searchWidget: Locator; readonly searchInput: Locator; readonly searchPrevButton: Locator; readonly searchNextButton: Locator; readonly searchClearButton: Locator; readonly errorMessage: Locator; readonly loadingIndicator: Locator; constructor(page: Page) { this.page = page; this.terminal = page.getByTestId('terminal'); this.containerInfo = page.getByTestId('container-info'); this.containerName = page.locator('[data-test="mainTitle"]'); this.containerState = page.getByTestId('container-state'); this.searchWidget = page.getByTestId('search-widget'); this.searchInput = page.getByTestId('search-input'); this.searchPrevButton = page.getByTestId('search-prev-btn'); this.searchNextButton = page.getByTestId('search-next-btn'); this.searchClearButton = page.getByTestId('search-clear-btn'); this.loadingIndicator = page.getByTestId('loading-indicator'); this.errorMessage = page.getByTestId('error-message'); } async waitForLogsToLoad() { await expect(this.terminal).toBeVisible(); await expect(this.loadingIndicator).toBeHidden({ timeout: 30_000 }); } async searchLogs(searchTerm: string) { await this.searchInput.fill(searchTerm); await this.searchInput.press('Enter'); } async scrollToBottom() { await this.page.evaluate(() => { const container = document.querySelector('[data-testid="terminal"]'); container?.__xtermTerminal?.scrollToBottom(); }); } async scrollToTop() { await this.page.evaluate(() => { const container = document.querySelector('[data-testid="terminal"]'); container?.__xtermTerminal?.scrollToTop(); }); } async getScrollPosition(): Promise { return await this.page.evaluate(() => { const container = document.querySelector('[data-testid="terminal"]'); return container?.__xtermTerminal?.buffer.active.viewportY ?? 0; }); } } ================================================ FILE: e2e/pages/container-shell-page.ts ================================================ import { expect } from '@playwright/test'; import type { Locator, Page } from '@playwright/test'; export class ContainerShellPage { readonly page: Page; readonly tab: Locator; readonly terminal: Locator; readonly notRunningBanner: Locator; readonly unsupportedBanner: Locator; constructor(page: Page) { this.page = page; this.tab = page.getByTestId('tab-shell'); this.terminal = page.getByTestId('terminal'); this.notRunningBanner = page.getByTestId('shell-not-running'); this.unsupportedBanner = page.getByTestId('shell-unsupported'); } async clickTab() { // Wait until the Shell tab is enabled (not disabled). // The tab is disabled when isRunning is false, which can happen briefly // after navigating to the container info page: the previous page's // beforeUnmount calls container-engine/unsubscribe (clearing the Vuex // containers store) before the new page's subscription has re-populated // it. Clicking a disabled tab does nothing and the test would time out. await expect(this.tab).not.toHaveClass(/\bdisabled\b/, { timeout: 30_000 }); await this.tab.click(); } async waitForTerminal() { await expect(this.terminal).toBeVisible({ timeout: 30_000 }); } /** Type a command and press Enter, using the hidden xterm textarea. */ async runCommand(command: string) { // ContainerShell auto-focuses the terminal when the shell tab becomes // active for real users, but Playwright's keyboard routing requires an // explicit click to track the focused element correctly. await this.terminal.click(); await this.page.keyboard.type(command); await this.page.keyboard.press('Enter'); } /** * Read terminal content via the xterm.js buffer API. * ContainerShell.vue deliberately exposes the terminal instance as * __xtermTerminal on the container element for e2e testing. We use the * buffer API rather than .xterm-rows textContent for two reasons: * 1. It avoids coupling to xterm's internal DOM structure, which can * change between versions. * 2. .xterm-rows textContent concatenates all rows without line * separators, so multiline patterns would never match even when the * text is present across consecutive rows. */ async getTerminalText(): Promise { return this.page.evaluate(() => { const el = document.querySelector('[data-testid="terminal"]'); const term = (el as any)?.__xtermTerminal; if (!term) return ''; const buf = term.buffer.active; const lines: string[] = []; for (let i = 0; i < buf.length; i++) { const line = buf.getLine(i); if (line) { lines.push(line.translateToString(true)); } } return lines.join('\n'); }); } /** Wait for a text string to appear anywhere in the terminal. */ async waitForOutput(text: string, timeout = 15_000) { await expect.poll( () => this.getTerminalText(), { timeout }, ).toContain(text); } /** * Wait until the shell session is ready to accept input. * ContainerShell.vue sets data-session-active="true" on the terminal element * when the container-exec/ready IPC event fires (which is also when * sessionActive becomes true and keyboard input starts being forwarded). * Using an HTML attribute rather than the xterm buffer means this assertion * works regardless of JS evaluation world boundaries in Playwright/Electron. */ async waitForShellReady(timeout = 20_000) { await expect(this.terminal).toHaveAttribute('data-session-active', 'true', { timeout }); } } ================================================ FILE: e2e/pages/containers-page.ts ================================================ import { Locator, Page, expect } from '@playwright/test'; type ActionString = 'info' | 'stop' | 'start' | 'delete'; export class ContainersPage { readonly page: Page; readonly table: Locator; readonly containers: Locator; readonly namespaceSelector: Locator; constructor(page: Page) { this.page = page; this.table = page.locator('.sortable-table'); this.containers = this.table.locator(`tr.main-row[data-node-id]`); this.namespaceSelector = page.locator('.select-namespace'); } getContainerRow(containerId: string) { return this.table.locator(`tr.main-row[data-node-id="${ containerId }"]`); } async waitForContainerToAppear(containerId: string, timeout = 30_000) { const containerRow = this.getContainerRow(containerId); await expect(containerRow).toBeVisible({ timeout }); } async clickContainerAction(containerId: string, action: ActionString) { const containerRow = this.getContainerRow(containerId); // The action button is in the actions column with class 'btn role-multi-action' await containerRow.locator('.btn.role-multi-action').click(); // Wait for the action menu to appear and click the action by text const actionText = { info: 'Info', stop: 'Stop', start: 'Start', delete: 'Delete', }[action]; const actionLocator = this.page .getByTestId('actionmenu') .getByText(actionText, { exact: true }); await actionLocator.click(); } async viewContainerInfo(containerId: string) { await this.clickContainerAction(containerId, 'info'); await this.page.getByTestId('tab-logs').click(); } async stopContainer(containerId: string) { await this.clickContainerAction(containerId, 'stop'); } async startContainer(containerId: string) { await this.clickContainerAction(containerId, 'start'); } async deleteContainer(containerId: string) { await this.clickContainerAction(containerId, 'delete'); } async getContainerCount(): Promise { const rows = this.table.locator('tr.main-row'); return await rows.count(); } async waitForTableToLoad() { await this.table.waitFor({ state: 'visible' }); } } ================================================ FILE: e2e/pages/diagnostics-page.ts ================================================ import type { Page, Locator } from '@playwright/test'; interface CheckerRows { muteButton: Locator; } export class DiagnosticsPage { readonly page: Page; readonly diagnostics: Locator; constructor(page: Page) { this.page = page; this.diagnostics = page.locator('[data-test="diagnostics"]'); } checkerRows(id: string): CheckerRows { return { muteButton: this.page.locator(`[data-test="diagnostics-mute-row-${ id }"]`) }; } } ================================================ FILE: e2e/pages/extensions-page.ts ================================================ import type { Page, Locator } from '@playwright/test'; export class ExtensionsPage { readonly page: Page; readonly cardEpinio: Locator; readonly buttonInstall: Locator; readonly tabInstalled: Locator; readonly tabCatalog: Locator; readonly navEpinio: Locator; constructor(page: Page) { this.page = page; this.cardEpinio = page.locator('[data-test="extension-card-epinio"]'); this.buttonInstall = page.locator('[data-test="button-install"]'); this.tabInstalled = page.locator('.tab >> text=Installed'); this.tabCatalog = page.locator('.tab >> text=Catalog'); this.navEpinio = page.locator('[data-test="extension-nav-epinio"]'); } } ================================================ FILE: e2e/pages/images-page.ts ================================================ import type { Page, Locator } from '@playwright/test'; export class ImagesPage { readonly page: Page; readonly fixedHeader: Locator; readonly table: Locator; readonly rows: Locator; constructor(page: Page) { this.page = page; this.fixedHeader = page.locator('.fixed-header-actions'); this.table = page.locator('[data-test="imagesTable"]'); this.rows = page.locator('[data-test="imagesTableRows"]'); } } ================================================ FILE: e2e/pages/k8s-page.ts ================================================ import type { Page, Locator } from '@playwright/test'; export class K8sPage { readonly page: Page; readonly engineRuntime: Locator; readonly memorySlider: Locator; readonly resetButton: Locator; readonly cpuSlider: Locator; readonly port: Locator; readonly enableKubernetes: Locator; constructor(page: Page) { this.page = page; this.memorySlider = page.locator('[id="memoryInGBWrapper"]'); this.resetButton = page.locator('[data-test="k8sResetBtn"]'); this.cpuSlider = page.locator('[id="numCPUWrapper"]'); this.engineRuntime = page.locator('.engine-selector'); this.port = page.locator('[data-test="portConfig"]'); this.enableKubernetes = page.locator('[data-test="enableKubernetes"]'); } } ================================================ FILE: e2e/pages/nav-page.ts ================================================ import util from 'util'; import { ContainersPage } from './containers-page'; import { DiagnosticsPage } from './diagnostics-page'; import { ExtensionsPage } from './extensions-page'; import { ImagesPage } from './images-page'; import { K8sPage } from './k8s-page'; import { PortForwardPage } from './portforward-page'; import { SnapshotsPage } from './snapshots-page'; import { TroubleshootingPage } from './troubleshooting-page'; import { VolumesPage } from './volumes-page'; import { WSLIntegrationsPage } from './wsl-integrations-page'; import { tool } from '../utils/TestUtils'; import type { Locator, Page } from '@playwright/test'; const pageConstructors = { General: (page: Page) => page, K8s: (page: Page) => new K8sPage(page), WSLIntegrations: (page: Page) => new WSLIntegrationsPage(page), Containers: (page: Page) => new ContainersPage(page), PortForwarding: (page: Page) => new PortForwardPage(page), Images: (page: Page) => new ImagesPage(page), Troubleshooting: (page: Page) => new TroubleshootingPage(page), Snapshots: (page: Page) => new SnapshotsPage(page), Diagnostics: (page: Page) => new DiagnosticsPage(page), Extensions: (page: Page) => new ExtensionsPage(page), Volumes: (page: Page) => new VolumesPage(page), }; export class NavPage { readonly page: Page; readonly progressBar: Locator; readonly mainTitle: Locator; readonly preferencesButton: Locator; constructor(page: Page) { this.page = page; this.mainTitle = page.locator('[data-test="mainTitle"]'); this.progressBar = page.locator('.progress'); this.preferencesButton = page.getByTestId('preferences-button'); } protected async getBackendState(): Promise { try { return JSON.parse(await tool('rdctl', 'api', '/v1/backend_state')).vmState; } catch { return 'NOT_READY'; } } protected async moveToNextState(currentState: string, timeout: number): Promise { const start = new Date().valueOf(); const expired = start + timeout; const delay = 500; // msec while (true) { try { const nextState = JSON.parse(await tool('rdctl', 'api', '/v1/backend_state')).vmState; if (nextState !== currentState) { return nextState; } } catch (e: any) { console.log(`Error trying to get backend state: ${ e }`); } const now = new Date().valueOf(); if (now >= expired) { throw new Error(`app watcher timed out at state ${ currentState } waiting for state change after ${ timeout / 1000 } seconds`); } await util.promisify(setTimeout)(delay); } } /** * This process wait the progress bar to be visible and then * waits until the progress bar be detached/hidden. * This is a workaround until we implement: * https://github.com/rancher-sandbox/rancher-desktop/issues/1217 */ /* STOPPED = 'STOPPED', // The engine is not running. STARTING = 'STARTING', // The engine is attempting to start. STARTED = 'STARTED', // The engine is started; the dashboard is not yet ready. STOPPING = 'STOPPING', // The engine is attempting to stop. ERROR = 'ERROR', // There is an error and we cannot recover automatically. DISABLED = 'DISABLED', // The container backend is ready but the Kubernetes engine is disabled. NOT_READY = 'NOT_READY', // call to `rdctl api /v1/backend_state` failed, so assume the server isn't ready */ // Implement a state-machine based on the backend states until we hit STOPPED, DISABLED, or ERROR, or timeout // Then verify the progress bar is gone async progressBecomesReady() { const timeout = 900_000; const maxAllowedStateChanges = 20; let i; let backendState = await this.getBackendState(); const finalStates = ['STARTED', 'ERROR', 'DISABLED']; for (i = 0; i < maxAllowedStateChanges && !finalStates.includes(backendState); i++) { if (backendState !== 'STARTING') { console.log(`Backend is currently at state ${ backendState }, waiting for a change...`); } backendState = await this.moveToNextState(backendState, timeout); } if (i === maxAllowedStateChanges && !finalStates.includes(backendState)) { throw new Error(`The backend is stuck in state ${ backendState }; doesn't look good`); } // Wait until progress bar be detached. With that we can make sure the services were started // This seems to sometimes return too early; actually check the result. while (await this.progressBar.count() > 0) { await this.progressBar.waitFor({ state: 'detached', timeout: Math.round(timeout * 0.6) }); } } /** * Navigate to a given tab, returning the page object model appropriate for * the destination tab. */ async navigateTo(tab: pageName): Promise>; async navigateTo(tab: keyof typeof pageConstructors) { const pageLoadHooks: Partial Promise>> = { Extensions: async function() { await this.page.waitForSelector('.extensions-page', { timeout: 60_000 }) }, }; await this.page.click(`.nav li[item="/${ tab }"] a`); await this.page.waitForURL(`**/${ tab }*`, { timeout: 60_000 }); await pageLoadHooks[tab]?.call(this); return pageConstructors[tab](this.page); } } ================================================ FILE: e2e/pages/portforward-page.ts ================================================ import type { Page, Locator } from '@playwright/test'; export class PortForwardPage { readonly page: Page; readonly fixedHeader: Locator; readonly content: Locator; readonly table: Locator; constructor(page: Page) { this.page = page; this.content = page.locator('.body > .content'); this.table = this.content.getByTestId('sortable-table-list-container'); this.fixedHeader = this.table.locator('.fixed-header-actions'); } } ================================================ FILE: e2e/pages/preferences/application.ts ================================================ import type { Page, Locator } from '@playwright/test'; export class ApplicationNav { readonly page: Page; readonly nav: Locator; readonly tabBehavior: Locator; readonly tabEnvironment: Locator; readonly tabGeneral: Locator; readonly administrativeAccess: Locator; readonly automaticUpdates: Locator; readonly automaticUpdatesCheckbox: Locator; readonly statistics: Locator; readonly autoStart: Locator; readonly background: Locator; readonly notificationIcon: Locator; readonly pathManagement: Locator; constructor(page: Page) { this.page = page; this.nav = page.locator('[data-test="nav-application"]'); this.tabBehavior = page.locator('.tab >> text=Behavior'); this.tabEnvironment = page.locator('.tab >> text=Environment'); this.tabGeneral = page.locator('.tab >> text=General'); this.administrativeAccess = page.locator('[data-test="administrativeAccess"]'); this.automaticUpdates = page.locator('[data-test="automaticUpdates"]'); this.automaticUpdatesCheckbox = page.locator('[data-test="automaticUpdatesCheckbox"]'); this.statistics = page.locator('[data-test="statistics"]'); this.autoStart = page.locator('[data-test="autoStart"]'); this.background = page.locator('[data-test="background"]'); this.notificationIcon = page.locator('[data-test="notificationIcon"]'); this.pathManagement = page.locator('[data-test="pathManagement"]'); } } ================================================ FILE: e2e/pages/preferences/containerEngine.ts ================================================ import type { Page, Locator } from '@playwright/test'; export class ContainerEngineNav { readonly page: Page; readonly nav: Locator; readonly tabGeneral: Locator; readonly tabAllowedImages: Locator; readonly containerEngine: Locator; readonly allowedImages: Locator; readonly allowedImagesCheckbox: Locator; readonly enabledLockedField: Locator; constructor(page: Page) { this.page = page; this.nav = page.locator('[data-test="nav-container-engine"]'); this.tabGeneral = page.locator('.tab >> text=General'); this.tabAllowedImages = page.locator('.tab >> text=Allowed Images'); this.containerEngine = page.locator('[data-test="containerEngine"]'); this.allowedImages = page.locator('[data-test="allowedImages"]'); this.allowedImagesCheckbox = page.getByTestId('allowedImagesCheckbox'); this.enabledLockedField = this.allowedImagesCheckbox.locator('.icon-lock'); } } ================================================ FILE: e2e/pages/preferences/index.ts ================================================ import { ApplicationNav } from './application'; import { ContainerEngineNav } from './containerEngine'; import { KubernetesNav } from './kubernetes'; import { VirtualMachineNav } from './virtualMachine'; import { WslNav } from './wsl'; import type { Page } from '@playwright/test'; export class PreferencesPage { readonly page: Page; readonly application: ApplicationNav; readonly virtualMachine: VirtualMachineNav; readonly containerEngine: ContainerEngineNav; readonly kubernetes: KubernetesNav; readonly wsl: WslNav; constructor(page: Page) { this.page = page; this.application = new ApplicationNav(page); this.virtualMachine = new VirtualMachineNav(page); this.containerEngine = new ContainerEngineNav(page); this.kubernetes = new KubernetesNav(page); this.wsl = new WslNav(page); } } ================================================ FILE: e2e/pages/preferences/kubernetes.ts ================================================ import type { Page, Locator } from '@playwright/test'; export class KubernetesNav { readonly page: Page; readonly nav: Locator; readonly kubernetesToggle: Locator; readonly kubernetesVersion: Locator; readonly kubernetesPort: Locator; readonly kubernetesOptions: Locator; readonly kubernetesVersionLockedFields: Locator; constructor(page: Page) { this.page = page; this.nav = page.locator('[data-test="nav-kubernetes"]'); this.kubernetesToggle = page.locator('[data-test="kubernetesToggle"]'); this.kubernetesVersion = page.locator('[data-test="kubernetesVersion"]'); this.kubernetesPort = page.locator('[data-test="kubernetesPort"]'); this.kubernetesOptions = page.locator('[data-test="kubernetesOptions"]'); this.kubernetesVersionLockedFields = page.locator('[data-test="kubernetesVersion"] > .select-k8s-version > .icon-lock'); } } ================================================ FILE: e2e/pages/preferences/virtualMachine.ts ================================================ import type { Page, Locator } from '@playwright/test'; export class VirtualMachineNav { readonly page: Page; readonly nav: Locator; readonly memory: Locator; readonly cpus: Locator; readonly mountType: Locator; readonly reverseSshFs: Locator; readonly ninep: Locator; readonly virtiofs: Locator; readonly cacheMode: Locator; readonly msizeInKib: Locator; readonly protocolVersion: Locator; readonly securityModel: Locator; readonly vmType: Locator; readonly qemu: Locator; readonly vz: Locator; readonly useRosetta: Locator; readonly tabHardware: Locator; readonly tabVolumes: Locator; readonly tabEmulation: Locator; constructor(page: Page) { this.page = page; this.nav = page.locator('[data-test="nav-virtual-machine"]'); this.memory = page.locator('#memoryInGBWrapper'); this.cpus = page.locator('#numCPUWrapper'); this.mountType = page.locator('[data-test="mountType"]'); this.reverseSshFs = page.locator('[data-test="reverse-sshfs"]'); this.ninep = page.locator('[data-test="9p"]'); this.virtiofs = page.locator('[data-test="virtiofs"]'); this.cacheMode = page.locator('[data-test="cacheMode"]'); this.msizeInKib = page.locator('[data-test="msizeInKib"]'); this.protocolVersion = page.locator('[data-test="protocolVersion"]'); this.securityModel = page.locator('[data-test="securityModel"]'); this.vmType = page.locator('[data-test="vmType"]'); this.qemu = page.locator('[data-test="QEMU"]'); this.vz = page.locator('[data-test="VZ"]'); this.useRosetta = page.locator('[data-test="useRosetta"]'); this.tabHardware = page.getByTestId('hardware'); this.tabVolumes = page.getByTestId('volumes'); this.tabEmulation = page.getByTestId('emulation'); } } ================================================ FILE: e2e/pages/preferences/wsl.ts ================================================ import type { Page, Locator } from '@playwright/test'; export class WslNav { readonly page: Page; readonly nav: Locator; readonly wslIntegrations: Locator; readonly addressTitle: Locator; readonly tabIntegrations: Locator; readonly tabProxy: Locator; constructor(page: Page) { this.page = page; this.nav = page.locator('[data-test="nav-wsl"]'); this.tabIntegrations = page.locator('.tab >> text=Integrations'); this.tabProxy = page.locator('.tab >> text=Proxy'); this.wslIntegrations = page.locator('[data-test="wslIntegrations"]'); this.addressTitle = page.locator('[data-test="addressTitle"]'); } } ================================================ FILE: e2e/pages/snapshots-page.ts ================================================ import { Locator, Page } from '@playwright/test'; export class SnapshotsPage { readonly page: Page; readonly snapshotsPage: Locator; readonly createSnapshotButton: Locator; readonly createSnapshotNameInput: Locator; readonly createSnapshotDescInput: Locator; constructor(page: Page) { this.page = page; this.snapshotsPage = page.locator('[data-test="snapshotsPage"]'); this.createSnapshotButton = page.locator('[data-test="createSnapshotButton"]'); this.createSnapshotNameInput = page.locator('[data-test="createSnapshotNameInput"]'); this.createSnapshotDescInput = page.locator('[data-test="createSnapshotDescInput"]'); } } ================================================ FILE: e2e/pages/troubleshooting-page.ts ================================================ import type { Page, Locator } from '@playwright/test'; export class TroubleshootingPage { readonly page: Page; readonly factoryResetButton: Locator; readonly logsButton: Locator; readonly troubleshooting: Locator; constructor(page: Page) { this.page = page; this.factoryResetButton = page.locator('[data-test="factoryResetButton"]'); this.logsButton = page.locator('[data-test="logsButton"]'); this.troubleshooting = page.locator('.troubleshooting'); } } ================================================ FILE: e2e/pages/volumes-page.ts ================================================ import { expect } from '@playwright/test'; import type { Locator, Page } from '@playwright/test'; type ActionString = 'browse' | 'delete'; const VOLUME_CELL_TEST_IDS = { name: 'volume-name-cell', driver: 'volume-driver-cell', mountpoint: 'volume-mountpoint-cell', created: 'volume-created-cell', } as const; export class VolumesPage { readonly page: Page; readonly table: Locator; readonly volumes: Locator; readonly namespaceSelector: Locator; readonly searchBox: Locator; readonly errorBanner: Locator; constructor(page: Page) { this.page = page; this.table = page.getByTestId('volumes-table'); this.volumes = this.table.locator(`tr.main-row[data-node-id]`); this.namespaceSelector = page.getByTestId('namespace-selector'); this.searchBox = page.getByTestId('search-input'); this.errorBanner = page.getByTestId('error-banner'); } getVolumeRow(volumeName: string) { return this.page.locator(`tr.main-row[data-node-id="${ volumeName }"]`); } async waitForVolumeToAppear(volumeName: string) { const volumeRow = this.getVolumeRow(volumeName); await expect(volumeRow).toBeVisible({ timeout: 15000 }); } async clickVolumeAction(volumeName: string, action: ActionString) { const volumeRow = this.getVolumeRow(volumeName); const actionButton = volumeRow.locator('.btn.role-multi-action.actions'); await expect(actionButton).toBeVisible({ timeout: 5_000 }); await actionButton.click(); const actionText = { browse: 'Browse Files', delete: 'Delete', }[action]; const actionMenu = this.page.getByTestId('actionmenu'); const actionLocator = actionMenu.getByText(actionText, { exact: true }); await actionLocator.click(); } async browseVolumeFiles(volumeName: string) { await this.clickVolumeAction(volumeName, 'browse'); } async deleteVolume(volumeName: string) { await this.clickVolumeAction(volumeName, 'delete'); } async getVolumeCount(): Promise { const rows = this.page.locator('tr.main-row'); return await rows.count(); } async waitForTableToLoad() { await expect(this.table).toBeVisible(); } async isVolumePresent(volumeName: string): Promise { const row = this.getVolumeRow(volumeName); return await row.isVisible().catch(() => false); } async searchVolumes(searchTerm: string) { await this.searchBox.fill(searchTerm); } getVolumeInfo(volumeName: string) { const volumeRow = this.getVolumeRow(volumeName); return { row: volumeRow, name: volumeRow.getByTestId(VOLUME_CELL_TEST_IDS.name), driver: volumeRow.getByTestId(VOLUME_CELL_TEST_IDS.driver), mountpoint: volumeRow.getByTestId(VOLUME_CELL_TEST_IDS.mountpoint), created: volumeRow.getByTestId(VOLUME_CELL_TEST_IDS.created), }; } async selectBulkVolumes(volumeNames: string[]) { for (const volumeName of volumeNames) { const volumeRow = this.getVolumeRow(volumeName); const checkbox = volumeRow.locator('.selection-checkbox'); await checkbox.click(); await expect(volumeRow.locator('input[type="checkbox"]')).toBeChecked(); } } async clickBulkDelete() { // Use the direct delete button that appears when items are selected const deleteButton = this.page .getByRole('button', { name: 'Delete' }) .first(); await deleteButton.click(); } async deleteBulkVolumes(volumeNames: string[]) { await this.selectBulkVolumes(volumeNames); await this.clickBulkDelete(); } } ================================================ FILE: e2e/pages/wsl-integrations-page.ts ================================================ import { expect } from '@playwright/test'; import type { Page, Locator } from '@playwright/test'; /** * CheckboxLocator handles assertions dealing with a Vue component. */ class CheckboxLocator { readonly locator: Locator; readonly checkbox: Locator; readonly name: Locator; readonly error: Locator; constructor(locator: Locator) { this.locator = locator; this.checkbox = locator.locator('input[type="checkbox"]'); this.name = locator.locator('.checkbox-label'); this.error = locator.locator('.checkbox-outer-container-description'); } click(...args: Parameters) { // The checkbox itself is not visible, so it can't be clicked. return this.locator.click(...args); } async assertEnabled(options?:{ timeout?: number }) { const elem = await this.locator.elementHandle(); expect(elem).toBeTruthy(); const result = await elem?.waitForSelector('label:not([class~="disabled"])', { state: 'attached', ...options }); expect(result).toBeTruthy(); } async assertDisabled(options?:{ timeout?: number }) { const elem = await this.locator.elementHandle(); expect(elem).toBeTruthy(); const result = await elem?.waitForSelector('label[class~="disabled"]', { state: 'attached', ...options }); expect(result).toBeTruthy(); } } export class WSLIntegrationsPage { readonly page: Page; readonly description: Locator; readonly mainTitle: Locator; readonly integrations: Locator; constructor(page: Page) { this.page = page; this.mainTitle = page.locator('[data-test="mainTitle"]'); this.description = page.locator('.description'); this.integrations = page.locator('[data-test="integration-list"]'); } getIntegration(distro: string): CheckboxLocator { const locator = this.integrations.locator(`[data-test="item-${ distro }"] .checkbox-outer-container`); return new CheckboxLocator(locator); } } ================================================ FILE: e2e/preferences.e2e.spec.ts ================================================ import os from 'os'; import { test, expect, _electron } from '@playwright/test'; import { NavPage } from './pages/nav-page'; import { PreferencesPage } from './pages/preferences'; import { createDefaultSettings, startRancherDesktop, teardown, tool } from './utils/TestUtils'; import { reopenLogs } from '@pkg/utils/logging'; import type { ElectronApplication, Page } from '@playwright/test'; let page: Page; /** * Using test.describe.serial make the test execute step by step, as described on each `test()` order * Playwright executes test in parallel by default and it will not work for our app backend loading process. * */ test.describe.serial('Main App Test', () => { let electronApp: ElectronApplication; let preferencesWindow: Page; test.beforeAll(async({ colorScheme }, testInfo) => { createDefaultSettings(); electronApp = await startRancherDesktop(testInfo); page = await electronApp.firstWindow(); await new NavPage(page).preferencesButton.click(); preferencesWindow = await electronApp.waitForEvent('window', page => /preferences/i.test(page.url())); }); test.afterAll(async({ colorScheme }, testInfo) => { await teardown(electronApp, testInfo); await tool('rdctl', 'reset', '--factory', '--verbose'); reopenLogs(); }); test('should open preferences modal', async() => { expect(preferencesWindow).toBeDefined(); // Wait for the window to actually load (i.e. transition from // app://index.html/#/preferences to app://index.html/#/Preferences#general) await preferencesWindow.waitForURL(/Preferences#/i); }); test('should show application page and render general tab', async() => { const { application } = new PreferencesPage(preferencesWindow); await expect(application.nav).toHaveClass('preferences-nav-item active'); if (!os.platform().startsWith('win')) { await expect(application.tabEnvironment).toBeVisible(); } else { await expect(application.tabEnvironment).not.toBeVisible(); } await expect(application.tabGeneral).toHaveText('General'); await expect(application.tabBehavior).toBeVisible(); await expect(application.automaticUpdates).toBeVisible(); await expect(application.statistics).toBeVisible(); await expect(application.autoStart).not.toBeVisible(); await expect(application.pathManagement).not.toBeVisible(); }); test('should render behavior tab', async() => { const { application } = new PreferencesPage(preferencesWindow); await application.tabBehavior.click(); await expect(application.autoStart).toBeVisible(); await expect(application.background).toBeVisible(); await expect(application.notificationIcon).toBeVisible(); await expect(application.administrativeAccess).not.toBeVisible(); await expect(application.pathManagement).not.toBeVisible(); }); test('should render environment tab', async() => { test.skip(os.platform() === 'win32', 'Environment tab not available on Windows'); const { application } = new PreferencesPage(preferencesWindow); await application.tabEnvironment.click(); await expect(application.administrativeAccess).not.toBeVisible(); await expect(application.automaticUpdates).not.toBeVisible(); await expect(application.statistics).not.toBeVisible(); await expect(application.pathManagement).toBeVisible(); }); test('should navigate to virtual machine and render hardware tab', async() => { test.skip(os.platform() === 'win32', 'Virtual Machine not available on Windows'); const { virtualMachine, application } = new PreferencesPage(preferencesWindow); await virtualMachine.nav.click(); await expect(application.nav).toHaveClass('preferences-nav-item'); await expect(virtualMachine.nav).toHaveClass('preferences-nav-item active'); await expect(virtualMachine.tabHardware).toHaveText('Hardware'); await expect(virtualMachine.tabVolumes).toBeVisible(); await expect(virtualMachine.tabVolumes).toHaveText('Volumes'); if (os.platform() === 'darwin') { await expect(virtualMachine.tabEmulation).toBeVisible(); await expect(virtualMachine.tabEmulation).toHaveText('Emulation'); } else { await expect(virtualMachine.tabEmulation).not.toBeVisible(); } await expect(virtualMachine.memory).toBeVisible(); await expect(virtualMachine.cpus).toBeVisible(); }); test('should render volumes tab', async() => { test.skip(os.platform() === 'win32', 'Virtual Machine not available on Windows'); const { virtualMachine } = new PreferencesPage(preferencesWindow); await virtualMachine.tabVolumes.click(); await expect(virtualMachine.mountType).toBeVisible(); await expect(virtualMachine.reverseSshFs).toBeVisible(); await expect(virtualMachine.ninep).toBeVisible(); await expect(virtualMachine.virtiofs).toBeVisible(); if (os.platform() === 'darwin') { if (parseInt(os.release()) < 22) { await expect(virtualMachine.virtiofs).toBeDisabled(); } else { await expect(virtualMachine.virtiofs).not.toBeDisabled(); } } if (os.platform() === 'darwin' && parseInt(os.release()) >= 23) { await expect(virtualMachine.virtiofs).toBeChecked(); } else { await expect(virtualMachine.reverseSshFs).toBeChecked(); } await virtualMachine.ninep.click(); await expect(virtualMachine.cacheMode).toBeVisible(); await expect(virtualMachine.msizeInKib).toBeVisible(); await expect(virtualMachine.protocolVersion).toBeVisible(); await expect(virtualMachine.securityModel).toBeVisible(); }); test('should render emulation tab on macOS', async() => { test.skip(os.platform() !== 'darwin', 'Emulation tab only available on macOS'); const { virtualMachine } = new PreferencesPage(preferencesWindow); await virtualMachine.tabEmulation.click(); await expect(virtualMachine.vmType).toBeVisible(); await expect(virtualMachine.qemu).toBeVisible(); await expect(virtualMachine.vz).toBeVisible(); if (parseInt(os.release()) < 22) { await expect(virtualMachine.vz).toBeDisabled(); } else { await expect(virtualMachine.vz).not.toBeDisabled(); await virtualMachine.vz.click({ position: { x: 10, y: 10 } }); await expect(virtualMachine.useRosetta).toBeVisible(); if (os.arch() === 'arm64') { await expect(virtualMachine.useRosetta).not.toBeDisabled(); } else { await expect(virtualMachine.useRosetta).toBeDisabled(); } } }); test('should navigate to container engine', async() => { const { containerEngine } = new PreferencesPage(preferencesWindow); await containerEngine.nav.click(); await expect(containerEngine.nav).toHaveClass('preferences-nav-item active'); await expect(containerEngine.containerEngine).toBeVisible(); await expect(containerEngine.tabGeneral).toBeVisible(); await expect(containerEngine.tabAllowedImages).toBeVisible(); }); test('should render allowed images tab after click on allowed images tab', async() => { const { containerEngine } = new PreferencesPage(preferencesWindow); await containerEngine.tabAllowedImages.click(); await expect(containerEngine.allowedImages).toBeVisible(); await expect(containerEngine.containerEngine).not.toBeVisible(); }); test('should navigate to kubernetes', async() => { const { kubernetes, containerEngine } = new PreferencesPage(preferencesWindow); await kubernetes.nav.click(); await expect(containerEngine.nav).toHaveClass('preferences-nav-item'); await expect(kubernetes.nav).toHaveClass('preferences-nav-item active'); await expect(kubernetes.kubernetesToggle).toBeVisible(); await expect(kubernetes.kubernetesVersion).toBeVisible(); await expect(kubernetes.kubernetesPort).toBeVisible(); await expect(kubernetes.kubernetesOptions).toBeVisible(); }); test('should navigate to WSL and render integrations tab', async() => { test.skip(os.platform() !== 'win32', 'WSL nav item not available on macOS & Linux'); const { wsl } = new PreferencesPage(preferencesWindow); await wsl.nav.click(); await expect(wsl.nav).toHaveClass('preferences-nav-item active'); await wsl.tabIntegrations.click(); await expect(wsl.wslIntegrations).toBeVisible(); }); test('should not render WSL nav item on macOS and Linux', async() => { test.skip(os.platform() === 'win32', 'WSL nav item is only available on Windows'); const { wsl } = new PreferencesPage(preferencesWindow); await expect(wsl.nav).not.toBeVisible(); }); test.describe.serial('Preferences State', () => { test.beforeAll(async() => { const { application } = new PreferencesPage(preferencesWindow); // Start this collection of tests on the environment tab await application.nav.click(); if (os.platform() === 'win32') { await application.tabGeneral.click(); } else { await application.tabEnvironment.click(); } // This collection of tests is about making sure that we persist state // in the preferences window, so we close the preferences window before // beginning this test collection. if (preferencesWindow) { await preferencesWindow.close(); } }); test.beforeEach(async() => { await new NavPage(page).preferencesButton.click(); preferencesWindow = await electronApp.waitForEvent('window', page => /preferences/i.test(page.url())); }); test.afterEach(async() => { if (preferencesWindow) { await preferencesWindow.close(); } }); test('should render environment tab after close and reopen preferences modal', async() => { test.skip(os.platform() === 'win32', 'Environment tab not available on Windows'); expect(preferencesWindow).toBeDefined(); const { application, containerEngine } = new PreferencesPage(preferencesWindow); await application.tabEnvironment.click(); await expect(application.nav).toHaveClass('preferences-nav-item active'); await expect(application.tabBehavior).toBeVisible(); await expect(application.tabEnvironment).toBeVisible(); await expect(application.administrativeAccess).not.toBeVisible(); await expect(application.automaticUpdates).not.toBeVisible(); await expect(application.statistics).not.toBeVisible(); await expect(application.pathManagement).toBeVisible(); // Move onto the container engine before starting the next test await containerEngine.nav.click(); await containerEngine.tabGeneral.click(); }); test('should render container engine page after close and reopen preferences modal', async() => { expect(preferencesWindow).toBeDefined(); // Wait for the window to actually load (i.e. transition from // app://index.html/#/preferences to app://index.html/#/Preferences#general) await preferencesWindow.waitForURL(/Preferences#/i); const { containerEngine } = new PreferencesPage(preferencesWindow); if (os.platform() === 'win32') { // We didn't run the previous test which landed on `tabGeneral`, so run that here. await containerEngine.nav.click(); await containerEngine.tabGeneral.click(); } await expect(containerEngine.nav).toHaveClass('preferences-nav-item active'); await expect(containerEngine.containerEngine).toBeVisible(); await expect(containerEngine.tabGeneral).toBeVisible(); await expect(containerEngine.tabAllowedImages).toBeVisible(); // Move onto the allowed images tab before the next test await containerEngine.tabAllowedImages.click(); }); test('should render allowed image tab in container engine page after close and reopen preferences modal', async() => { expect(preferencesWindow).toBeDefined(); // Wait for the window to actually load (i.e. transition from // app://index.html/#/preferences to app://index.html/#/Preferences#general) await preferencesWindow.waitForURL(/Preferences#/i); const { containerEngine } = new PreferencesPage(preferencesWindow); await expect(containerEngine.nav).toHaveClass('preferences-nav-item active'); await expect(containerEngine.allowedImages).toBeVisible(); await expect(containerEngine.containerEngine).not.toBeVisible(); }); }); }); ================================================ FILE: e2e/quit-on-close.e2e.spec.ts ================================================ import { test, expect, ElectronApplication } from '@playwright/test'; import { createDefaultSettings, reportAsset, startRancherDesktop, teardown } from './utils/TestUtils'; /** * Using test.describe.serial make the test execute step by step, as described on each `test()` order * Playwright executes test in parallel by default and it will not work for our app backend loading process. * */ test.describe.serial('quitOnClose setting', () => { test('should quit when quitOnClose is true and window is closed', async({ colorScheme }, testInfo) => { createDefaultSettings({ application: { window: { quitOnClose: true } } }); const electronApp = await startRancherDesktop(testInfo, { logVariant: 'quitOnCloseTrue' }); await electronApp.firstWindow(); await expect(closeWindowsAndCheckQuit(electronApp)).resolves.toBe(true); // Don't call teardown[App] here, because the app already exited. await electronApp.context().tracing.stop({ path: reportAsset(testInfo, 'trace') }); }); test('should not quit when quitOnClose is false and window is closed', async({ colorScheme }, testInfo) => { createDefaultSettings({ application: { window: { quitOnClose: false } } }); const electronApp = await startRancherDesktop(testInfo, { logVariant: 'quitOnCloseFalse' }); try { await electronApp.firstWindow(); await expect(closeWindowsAndCheckQuit(electronApp)).resolves.toBe(false); } finally { try { await teardown(electronApp, testInfo); } catch { } } }); }); /** * Closes all of the windows in a running app. Returns a promise that * resolves to true when the app has quit within a certain period of time, * or that resolves to false when the app does not quit within that period * of time. * */ function closeWindowsAndCheckQuit(electronApp: ElectronApplication): Promise { return electronApp.evaluate(async({ app, BrowserWindow }) => { const quitReady = new Promise((resolve) => { app.on('will-quit', () => resolve(true)); app.on('window-all-closed', () => { setTimeout(() => resolve(false), 3_000); }); }); await Promise.all(BrowserWindow.getAllWindows().map((window) => { return new Promise((resolve) => { window.on('closed', resolve); window.close(); }); })); return await quitReady; }); } ================================================ FILE: e2e/rdctl.e2e.spec.ts ================================================ /* Copyright © 2022 SUSE LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * This file includes end-to-end testing for the HTTP control interface */ import fs from 'fs'; import os from 'os'; import path from 'path'; import { expect, test } from '@playwright/test'; import _ from 'lodash'; import yaml from 'yaml'; import { NavPage } from './pages/nav-page'; import { getAlternateSetting, kubectl, retry, startSlowerDesktop, teardown, } from './utils/TestUtils'; import { CacheMode, ContainerEngine, CURRENT_SETTINGS_VERSION, defaultSettings, MountType, ProtocolVersion, SecurityModel, Settings, VMType, } from '@pkg/config/settings'; import { PathManagementStrategy } from '@pkg/integrations/pathManager'; import { ServerState } from '@pkg/main/commandServer/httpCommandServer'; import { spawnFile } from '@pkg/utils/childProcess'; import paths from '@pkg/utils/paths'; import { RecursivePartial } from '@pkg/utils/typeUtils'; import type { ElectronApplication, Page } from '@playwright/test'; test.describe('Command server', () => { let electronApp: ElectronApplication; let serverState: ServerState; let page: Page; const ENOENTMessage = os.platform() === 'win32' ? 'The system cannot find the file specified' : 'no such file or directory'; const appPath = path.dirname(import.meta.dirname); async function doRequest(path: string, body = '', method = 'GET') { const url = `http://127.0.0.1:${ serverState.port }/${ path.replace(/^\/*/, '') }`; const auth = `${ serverState.user }:${ serverState.password }`; const init: RequestInit = { method, headers: { Authorization: `Basic ${ Buffer.from(auth) .toString('base64') }`, }, }; if (body) { init.body = body; } return await fetch(url, init); } function rdctlPath() { return path.join(appPath, 'resources', os.platform(), 'bin', os.platform() === 'win32' ? 'rdctl.exe' : 'rdctl'); } async function rdctl(commandArgs: string[]): Promise< { stdout: string, stderr: string, error?: any }> { try { return await spawnFile(rdctlPath(), commandArgs, { stdio: 'pipe' }); } catch (err: any) { return { stdout: err?.stdout ?? '', stderr: err?.stderr ?? '', error: err, }; } } async function rdctlWithStdin(inputFile: string, commandArgs: string[]): Promise< { stdout: string, stderr: string, error?: any }> { let stream: fs.ReadStream | null = null; try { const fd = await fs.promises.open(inputFile, 'r'); stream = fd.createReadStream(); return await spawnFile(rdctlPath(), commandArgs, { stdio: [stream, 'pipe', 'pipe'] }); } catch (err: any) { return { stdout: err?.stdout ?? '', stderr: err?.stderr ?? '', error: err, }; } finally { stream?.close(); } } function verifySettingsKeys(settings: Record) { expect(new Set(Object.keys(defaultSettings))) .toEqual(new Set(Object.keys(settings))); } test.describe.configure({ mode: 'serial' }); test.beforeAll(async({ colorScheme }, testInfo) => { [electronApp, page] = await startSlowerDesktop(testInfo, { kubernetes: { enabled: true } }); }); test.afterAll(({ colorScheme }, testInfo) => teardown(electronApp, testInfo)); test('should load Kubernetes API', async() => { const navPage = new NavPage(page); await navPage.progressBecomesReady(); expect(await retry(() => kubectl('cluster-info'))).toContain('is running at'); }); test('should emit connection information', async() => { const dataPath = path.join(paths.appHome, 'rd-engine.json'); const dataRaw = await fs.promises.readFile(dataPath, 'utf-8'); serverState = JSON.parse(dataRaw); expect(serverState).toEqual(expect.objectContaining({ user: expect.any(String), password: expect.any(String), port: expect.any(Number), pid: expect.any(Number), })); }); test('should require authentication, settings request', async() => { const url = `http://127.0.0.1:${ serverState.port }/v1/settings`; const resp = await fetch(url); expect(resp).toEqual(expect.objectContaining({ ok: false, status: 401, })); }); test('should emit CORS headers, settings request', async() => { const resp = await doRequest('/v1/settings', '', 'OPTIONS'); expect({ ...resp, ok: !!resp.ok, headers: Object.fromEntries(resp.headers.entries()), }).toEqual(expect.objectContaining({ ok: true, headers: expect.objectContaining({ 'access-control-allow-headers': 'Authorization', 'access-control-allow-methods': 'GET, PUT, DELETE', 'access-control-allow-origin': '*', }), })); }); test('should be able to get settings', async() => { const resp = await doRequest('/v1/settings'); expect({ ...resp, ok: !!resp.ok, headers: Object.fromEntries(resp.headers.entries()), }).toEqual(expect.objectContaining({ ok: true, headers: expect.objectContaining({ 'access-control-allow-headers': 'Authorization', 'access-control-allow-methods': 'GET, PUT, DELETE', 'access-control-allow-origin': '*', }), })); expect(await resp.json()).toHaveProperty('kubernetes'); }); test('setting existing settings should be a no-op', async() => { let resp = await doRequest('/v1/settings'); const rawSettings = await resp.text(); resp = await doRequest('/v1/settings', rawSettings, 'PUT'); expect({ ok: resp.ok, status: resp.status, body: await resp.text(), }).toEqual({ ok: true, status: 202, body: expect.stringContaining('no changes necessary'), }); }); test('should not update values when the /settings payload has errors', async() => { let resp = await doRequest('/v1/settings'); const settings = await resp.json(); const desiredEnabled = !settings.kubernetes.enabled; const desiredEngine = 'flip'; const desiredVersion = /1.29.4/.test(settings.kubernetes.version) ? 'v1.19.1' : 'v1.29.4'; const requestedSettings = _.merge({}, settings, { version: CURRENT_SETTINGS_VERSION, containerEngine: { name: { desiredEngine }, allowedImages: { enabled: !settings.containerEngine.allowedImages.enabled }, }, kubernetes: { enabled: desiredEnabled, version: desiredVersion, }, }); const resp2 = await doRequest('/v1/settings', JSON.stringify(requestedSettings), 'PUT'); expect(resp2.ok).toBeFalsy(); expect(resp2.status).toEqual(400); // Now verify that the specified values did not get updated. resp = await doRequest('/v1/settings'); const refreshedSettings = await resp.json(); expect(refreshedSettings).toEqual(settings); }); test('should return multiple error messages, settings request', async() => { const newSettings: Record = { version: CURRENT_SETTINGS_VERSION, application: { stoinks: 'yikes!', // should be ignored telemetry: { enabled: { oops: 15 } }, }, containerEngine: { name: { status: 'should be a scalar' } }, virtualMachine: { memoryInGB: 'carl' }, WSL: { integrations: "ceci n'est pas un objet" }, portForwarding: 'bob', }; const resp2 = await doRequest('/v1/settings', JSON.stringify(newSettings), 'PUT'); expect(resp2.ok).toBeFalsy(); expect(resp2.status).toEqual(400); const body = await resp2.text(); const expectedWSL = { win32: `Proposed field "WSL.integrations" should be an object, got .`, lima: `Changing field "WSL.integrations" via the API isn't supported.`, }[os.platform() === 'win32' ? 'win32' : 'lima']; const expectedMemory = { win32: `Changing field "virtualMachine.memoryInGB" via the API isn't supported.`, lima: `Invalid value for "virtualMachine.memoryInGB": <"carl">`, }[os.platform() === 'win32' ? 'win32' : 'lima']; const expectedLines = [ 'errors in attempt to update settings:', expectedWSL, expectedMemory, `Invalid value for "containerEngine.name": <{\"status\":\"should be a scalar\"}>; must be one of ["containerd","moby","docker"]`, 'Setting "portForwarding" should wrap an inner object, but got .', 'Invalid value for "application.telemetry.enabled": <{"oops":15}>', ]; expect(body.split(/\r?\n/g).sort()).toEqual(expect.arrayContaining(expectedLines.sort())); }); test('should reject invalid JSON, settings request', async() => { const resp = await doRequest('/v1/settings', '{"missing": "close-brace"', 'PUT'); expect(resp.ok).toBeFalsy(); expect(resp.status).toEqual(400); await expect(resp.text()).resolves.toContain('error processing JSON request block'); }); test('should reject empty payload, settings request', async() => { const resp = await doRequest('/v1/settings', '', 'PUT'); expect(resp.ok).toBeFalsy(); expect(resp.status).toEqual(400); await expect(resp.text()).resolves.toContain('no settings specified in the request'); }); test('version-only path of a nonexistent version should 404', async() => { const resp = await doRequest('/v99-bottles-of-beer-on-the-wall'); expect(resp.ok).toBeFalsy(); expect(resp.status).toEqual(404); await expect(resp.text()).resolves.toContain('Unknown command: GET /v99-bottles-of-beer-on-the-wall'); }); test('should not restart on unrelated changes', async() => { let resp = await doRequest('/v1/settings'); expect(resp.ok).toBeTruthy(); const telemetry = (await resp.json() as Settings).application.telemetry.enabled; resp = await doRequest('/v1/settings', JSON.stringify({ version: CURRENT_SETTINGS_VERSION, application: { telemetry: { enabled: !telemetry } } }), 'PUT'); expect(resp.ok).toBeTruthy(); await expect(resp.text()).resolves.toContain('no restart required'); }); test('should complain about a missing version field', async() => { let resp = await doRequest('/v1/settings'); expect(resp.ok).toBeTruthy(); const body: RecursivePartial = await resp.json(); delete body.version; if (body?.application?.telemetry) { body.application.telemetry.enabled = !body.application.telemetry.enabled; } resp = await doRequest('/v1/settings', JSON.stringify(body), 'PUT'); expect(resp.ok).toBeFalsy(); await expect(resp.text()).resolves.toContain(`updating settings requires specifying an API version, but no version was specified`); }); test('should complain about an invalid version field', async() => { let resp = await doRequest('/v1/settings'); expect(resp.ok).toBeTruthy(); const body: RecursivePartial = await resp.json(); const badVersion = 'not a number'; // Override typescript's checking so we can verify that the server rejects the // invalid value for the `version` field. body.version = badVersion as any; if (body?.application?.telemetry) { body.application.telemetry.enabled = !body.application.telemetry.enabled; } resp = await doRequest('/v1/settings', JSON.stringify(body), 'PUT'); expect(resp.ok).toBeFalsy(); await expect(resp.text()).resolves.toContain(`updating settings requires specifying an API version, but "${ badVersion }" is not a proper config version`); }); test('should require authentication, transient settings request', async() => { const url = `http://127.0.0.1:${ serverState.port }/v1/transient_settings`; const resp = await fetch(url); expect(resp).toEqual(expect.objectContaining({ ok: false, status: 401, })); }); test('should emit CORS headers, transient settings request', async() => { const resp = await doRequest('/v1/transient_settings', '', 'OPTIONS'); expect({ ...resp, ok: !!resp.ok, headers: Object.fromEntries(resp.headers.entries()), }).toEqual(expect.objectContaining({ ok: true, headers: expect.objectContaining({ 'access-control-allow-headers': 'Authorization', 'access-control-allow-methods': 'GET, PUT, DELETE', 'access-control-allow-origin': '*', }), })); }); test('should be able to get transient settings', async() => { const resp = await doRequest('/v1/transient_settings'); expect({ ...resp, ok: !!resp.ok, headers: Object.fromEntries(resp.headers.entries()), }).toEqual(expect.objectContaining({ ok: true, headers: expect.objectContaining({ 'access-control-allow-headers': 'Authorization', 'access-control-allow-methods': 'GET, PUT, DELETE', 'access-control-allow-origin': '*', }), })); expect(await resp.json()).toHaveProperty('noModalDialogs'); }); test('setting existing transient settings should be a no-op', async() => { let resp = await doRequest('/v1/transient_settings'); const rawSettings = await resp.text(); resp = await doRequest('/v1/transient_settings', rawSettings, 'PUT'); expect({ ok: resp.ok, status: resp.status, body: await resp.text(), }).toEqual({ ok: true, status: 202, body: expect.stringContaining('No changes necessary'), }); }); test('should not update values when the /transient_settings navItem payload is invalid', async() => { let resp = await doRequest('/v1/transient_settings'); const transientSettings = await resp.json(); const requestedSettings = _.merge({}, transientSettings, { preferences: { navItem: { current: 'foo', bar: 'bar' } } }); const resp2 = await doRequest('/v1/transient_settings', JSON.stringify(requestedSettings), 'PUT'); expect(resp2.ok).toBeFalsy(); expect(resp2.status).toEqual(400); // Now verify that the specified values did not get updated. resp = await doRequest('/v1/transient_settings'); const refreshedSettings = await resp.json(); expect(refreshedSettings).toEqual(transientSettings); }); test('should not update values when the /transient_settings payload has invalid current navItem name', async() => { let resp = await doRequest('/v1/transient_settings'); const transientSettings = await resp.json(); const requestedSettings = _.merge({}, transientSettings, { preferences: { navItem: { current: 'foo' } } }); const resp2 = await doRequest('/v1/transient_settings', JSON.stringify(requestedSettings), 'PUT'); expect(resp2.ok).toBeFalsy(); expect(resp2.status).toEqual(400); // Now verify that the specified values did not get updated. resp = await doRequest('/v1/transient_settings'); const refreshedSettings = await resp.json(); expect(refreshedSettings).toEqual(transientSettings); }); test('should not update values when the /transient_settings payload has invalid sub-tabs for Application preference page', async() => { let resp = await doRequest('/v1/transient_settings'); const transientSettings = await resp.json(); const requestedSettings = _.merge({}, transientSettings, { preferences: { navItem: { current: 'Application', currentTabs: { Application: 'foo' } } } }); const resp2 = await doRequest('/v1/transient_settings', JSON.stringify(requestedSettings), 'PUT'); expect(resp2.ok).toBeFalsy(); expect(resp2.status).toEqual(400); // Now verify that the specified values did not get updated. resp = await doRequest('/v1/transient_settings'); const refreshedSettings = await resp.json(); expect(refreshedSettings).toEqual(transientSettings); }); test('should not update values when the /transient_settings payload has invalid sub-tabs for Container Engine preference page', async() => { let resp = await doRequest('/v1/transient_settings'); const transientSettings = await resp.json(); const requestedSettings = _.merge( {}, transientSettings, { preferences: { navItem: { currentTabs: { 'Container Engine': 'behavior' } } } }, ); const resp2 = await doRequest('/v1/transient_settings', JSON.stringify(requestedSettings), 'PUT'); expect(resp2.ok).toBeFalsy(); expect(resp2.status).toEqual(400); // Now verify that the specified values did not get updated. resp = await doRequest('/v1/transient_settings'); const refreshedSettings = await resp.json(); expect(refreshedSettings).toEqual(transientSettings); }); test('should not update values when the /transient_settings payload contains sub-tabs for a page not supporting sub-tabs: WSL / Virtual Machine', async() => { let resp = await doRequest('/v1/transient_settings'); const transientSettings = await resp.json(); const requestedSettings = _.merge( {}, transientSettings, { preferences: { navItem: { currentTabs: { [process.platform === 'win32' ? 'WSL' : 'Virtual Machine']: 'behavior' } } } }, ); const resp2 = await doRequest('/v1/transient_settings', JSON.stringify(requestedSettings), 'PUT'); expect(resp2.ok).toBeFalsy(); expect(resp2.status).toEqual(400); // Now verify that the specified values did not get updated. resp = await doRequest('/v1/transient_settings'); const refreshedSettings = await resp.json(); expect(refreshedSettings).toEqual(transientSettings); }); test('should not update values when the /transient_settings payload contains sub-tabs for a page not supporting sub-tabs: Kubernetes', async() => { let resp = await doRequest('/v1/transient_settings'); const transientSettings = await resp.json(); const requestedSettings = _.merge( {}, transientSettings, { preferences: { navItem: { currentTabs: { Kubernetes: 'behavior' } } } }, ); const resp2 = await doRequest('/v1/transient_settings', JSON.stringify(requestedSettings), 'PUT'); expect(resp2.ok).toBeFalsy(); expect(resp2.status).toEqual(400); // Now verify that the specified values did not get updated. resp = await doRequest('/v1/transient_settings'); const refreshedSettings = await resp.json(); expect(refreshedSettings).toEqual(transientSettings); }); test('should reject invalid JSON, transient settings request', async() => { const resp = await doRequest('/v1/transient_settings', '{"missing": "close-brace"', 'PUT'); expect(resp.ok).toBeFalsy(); expect(resp.status).toEqual(400); await expect(resp.text()).resolves.toContain('error processing JSON request block'); }); test('should reject empty payload, transient settings request', async() => { const resp = await doRequest('/v1/transient_settings', '', 'PUT'); expect(resp.ok).toBeFalsy(); expect(resp.status).toEqual(400); await expect(resp.text()).resolves.toContain('no settings specified in the request'); }); test.describe('v0 API', () => { const endpoints = { GET: ['diagnostic_categories', 'diagnostic_checks', 'diagnostic_ids', 'settings', 'transient_settings'], PUT: ['factory_reset', 'propose_settings', 'settings', 'shutdown', 'transient_settings'], POST: ['diagnostic_checks'], }; test('should no longer work', async() => { for (const method in endpoints) { for (const endpoint of endpoints[method as 'GET' | 'PUT']) { const resp = await doRequest(`/v0/${ endpoint }`, '', method); expect({ ok: resp.ok, status: resp.status, body: await resp.text(), }).toEqual({ ok: false, status: 400, body: `Invalid version "/v0" for endpoint "${ method } /v0/${ endpoint }" - use "/v1/${ endpoint }"`, }); } } }); }); test.describe('rdctl', () => { test.describe('config-file and parameters', () => { test.describe("when the config-file doesn't exist", () => { let parameters: string[]; const configFilePath = path.join(paths.appHome, 'rd-engine.json'); const backupPath = path.join(paths.appHome, 'rd-engine.json.bak'); test.beforeAll(async() => { const dataRaw = await fs.promises.readFile(configFilePath, 'utf-8'); serverState = JSON.parse(dataRaw); parameters = [`--password=${ serverState.password }`, `--port=${ serverState.port }`, `--user=${ serverState.user }`, ]; await expect(fs.promises.rename(configFilePath, backupPath)).resolves.toBeUndefined(); }); test.afterAll(async() => { await expect(fs.promises.rename(backupPath, configFilePath)).resolves.toBeUndefined(); }); test('it complains with no parameters,', async() => { const { stdout, stderr, error } = await rdctl(['list-settings']); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining(`Error: failed to get connection info: open ${ configFilePath }: ${ ENOENTMessage }`), stdout: '', }); expect(stderr).not.toContain('Usage:'); }); test('it works with all parameters,', async() => { const { stdout, stderr, error } = await rdctl(parameters.concat(['list-settings'])); expect({ stdout, stderr, error, }).toEqual({ error: undefined, stderr: '', stdout: expect.stringContaining('"kubernetes":'), }); verifySettingsKeys(JSON.parse(stdout)); }); test("it complains when some parameters aren't specified", async() => { for (let idx = 0; idx < parameters.length; idx += 1) { const partialParameters = parameters.slice(0, idx).concat(parameters.slice(idx + 1)); const { stdout, stderr, error } = await rdctl(partialParameters.concat(['list-settings'])); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining(`Error: failed to get connection info: open ${ configFilePath }: ${ ENOENTMessage }`), stdout: '', }); expect(stderr).not.toContain('Usage:'); } }); test.describe('when a nonexistent config file is specified', () => { test('it fails even when all parameters are specified', async() => { const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-fake-docker')); try { const configFile = path.join(tmpDir, 'config.json'); // Do not actually create configFile const { stdout, stderr, error } = await rdctl(parameters.concat(['list-settings', '--config-path', configFile])); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining(`Error: failed to get connection info: open ${ configFile }: ${ ENOENTMessage }`), stdout: '', }); expect(stderr).not.toContain('Usage:'); } finally { await fs.promises.rm(tmpDir, { recursive: true }); } }); }); }); }); test('should show settings and nil-update settings', async() => { const { stdout, stderr, error } = await rdctl(['list-settings']); expect({ stdout, stderr, error, }).toEqual({ error: undefined, stderr: '', stdout: expect.stringContaining('"kubernetes":'), }); const settings = JSON.parse(stdout); verifySettingsKeys(settings); const args = ['set', '--container-engine', settings.containerEngine.name, `--kubernetes-enabled=${ !!settings.kubernetes.enabled }`, '--kubernetes-version', settings.kubernetes.version]; const result = await rdctl(args); expect(result).toMatchObject({ stderr: '', stdout: expect.stringContaining('Status: no changes necessary.'), }); }); test.describe('set', () => { const unsupportedPrefsByPlatform: Partial> = { win32: [ ['application.admin-access', true], ['application.path-management-strategy', 'rcfiles'], ['experimental.virtual-machine.mount.9p.cache-mode', CacheMode.MMAP], ['experimental.virtual-machine.mount.9p.msize-in-kib', 128], ['experimental.virtual-machine.mount.9p.protocol-version', ProtocolVersion.NINEP2000_L], ['experimental.virtual-machine.mount.9p.security-model', SecurityModel.NONE], ['virtual-machine.memory-in-gb', 10], ['virtual-machine.mount.type', MountType.NINEP], ['virtual-machine.number-cpus', 10], ['virtual-machine.type', VMType.VZ], ['virtual-machine.use-rosetta', true], ], darwin: [ ['kubernetes.ingress.localhost-only', true], ], linux: [ ['experimental.virtual-machine.proxy.enabled', true], ['virtual-machine.type', VMType.VZ], ['virtual-machine.use-rosetta', true], ], }; const unsupportedOptions = unsupportedPrefsByPlatform[os.platform()] ?? []; const commonOptions = [ 'container-engine.name', 'container-engine.allowed-images.enabled', 'kubernetes.version', 'kubernetes.port', 'kubernetes.options.traefik', 'port-forwarding.include-kubernetes-services', ]; test('complains when no args are given', async() => { const { stdout, stderr, error } = await rdctl(['set']); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining('Error: set command: no settings to change were given'), stdout: '', }); expect(stderr).toContain('Usage:'); const options = stderr.split(/\n/) .filter(line => /^\s+--/.test(line)) .map(line => (/\s+--([-.\w]+)\s/.exec(line) || [])[1]) .filter(line => line); // This part is a bit subtle // Require that the received options contain at least all the common options expect(options).toEqual(expect.arrayContaining(commonOptions)); // We can't use `not.toEqual.arrayContaining` for the unsupported options because if the received // list contains some but not all of the unsupported options the not-test will still succeed for (const option of unsupportedOptions) { expect(options).not.toContain(option[0]); } }); test('complains when option value missing', async() => { const { stdout, stderr, error } = await rdctl(['set', '--container-engine']); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining('Error: flag needs an argument: --container-engine'), stdout: '', }); expect(stderr).toContain('Usage:'); }); test('complains when non-boolean option value specified', async() => { const { stdout, stderr, error } = await rdctl(['set', '--kubernetes-enabled=gorb']); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining('Error: invalid argument "gorb" for "--kubernetes-enabled" flag: strconv.ParseBool: parsing "gorb": invalid syntax'), stdout: '', }); expect(stderr).toContain('Usage:'); }); test('complains when invalid engine specified', async() => { const myEngine = 'giblets'; const { stdout, stderr, error } = await rdctl(['set', `--container-engine=${ myEngine }`]); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining(`Error: invalid value for option --container-engine: "${ myEngine }"; must be 'containerd', 'docker', or 'moby'`), stdout: '', }); expect(stderr).not.toContain('Error: errors in attempt to update settings:'); expect(stderr).not.toContain('Usage:'); }); test('complains when server rejects a proposed version', async() => { const { stdout, stderr, error } = await rdctl(['set', '--kubernetes-version=karl']); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringMatching(/Error: errors in attempt to update settings:\s+Kubernetes version "karl" not found./), stdout: '', }); expect(stderr).not.toContain('Usage:'); }); test.describe('settings v5 migration', () => { /** * Note issue https://github.com/rancher-sandbox/rancher-desktop/issues/3829 * calls for removing unrecognized fields in the existing settings.json file * Currently we're ignoring unrecognized fields in the PUT payload -- to complain about * them calls for another issue. */ test('rejects old settings', async() => { const oldSettings: Settings = JSON.parse((await rdctl(['list-settings'])).stdout); const body: any = { // type 'any' because as far as the current configuration code is concerned, // it's an object with random fields and values version: CURRENT_SETTINGS_VERSION, kubernetes: { memoryInGB: oldSettings.virtualMachine.memoryInGB + 1, numberCPUs: oldSettings.virtualMachine.numberCPUs + 1, containerEngine: getAlternateSetting(oldSettings, 'containerEngine.name', ContainerEngine.CONTAINERD, ContainerEngine.MOBY), suppressSudo: oldSettings.application.adminAccess, }, telemetry: !oldSettings.application.telemetry.enabled, updater: !oldSettings.application.updater.enabled, debug: !oldSettings.application.debug, }; const addPathManagementStrategy = (oldSettings: Settings, body: any) => { body.pathManagementStrategy = getAlternateSetting(oldSettings, 'application.pathManagementStrategy', PathManagementStrategy.Manual, PathManagementStrategy.RcFiles); }; switch (os.platform()) { case 'darwin': body.kubernetes.experimental ??= {}; addPathManagementStrategy(oldSettings, body); break; case 'linux': addPathManagementStrategy(oldSettings, body); break; case 'win32': body.kubernetes.WSLIntegrations ??= {}; body.kubernetes.WSLIntegrations.bosco = true; } const { stdout, stderr, error } = await rdctl(['api', '/v1/settings', '-X', 'PUT', '-b', JSON.stringify(body)]); expect({ stdout, stderr, error, }).toEqual({ stdout: expect.stringContaining('no changes necessary'), stderr: '', error: undefined, }); const newSettings: Settings = JSON.parse((await rdctl(['list-settings'])).stdout); expect(newSettings).toEqual(oldSettings); }); test('accepts new settings', async() => { const oldSettings: Settings = JSON.parse((await rdctl(['list-settings'])).stdout); const body: RecursivePartial = { ...(os.platform() === 'win32' ? {} : { virtualMachine: { memoryInGB: oldSettings.virtualMachine.memoryInGB + 1, numberCPUs: oldSettings.virtualMachine.numberCPUs + 1, }, }), version: CURRENT_SETTINGS_VERSION, application: { // XXX: Can't change adminAccess until we can process the sudo-request dialog (and decline it) // adminAccess: !oldSettings.application.adminAccess, telemetry: { enabled: !oldSettings.application.telemetry.enabled }, updater: { enabled: !oldSettings.application.updater.enabled }, debug: !oldSettings.application.debug, }, // This field is to force a restart kubernetes: { port: oldSettings.kubernetes.port + 1 }, }; if (process.platform !== 'win32' && body.application !== undefined) { body.application.pathManagementStrategy = getAlternateSetting(oldSettings, 'application.pathManagementStrategy', PathManagementStrategy.Manual, PathManagementStrategy.RcFiles); } const { stdout, stderr, error } = await rdctl(['api', '/v1/settings', '-X', 'PUT', '-b', JSON.stringify(body)]); expect({ stdout, stderr, error, }).toEqual({ stdout: expect.stringContaining('reconfiguring Rancher Desktop to apply changes'), stderr: '', error: undefined, }); const newSettings: Settings = JSON.parse((await rdctl(['list-settings'])).stdout); expect(newSettings).toEqual(_.merge(oldSettings, body)); // And now reinstate the old prefs so other tests that count on them will pass. const result = await rdctl(['api', '/v1/settings', '-X', 'PUT', '-b', JSON.stringify(oldSettings)]); expect(result.stderr).toEqual(''); const navPage = new NavPage(page); await navPage.progressBecomesReady(); }); }); test('complains about options not intended for current platform', async() => { // playwright doesn't support test.each // See https://github.com/microsoft/playwright/issues/7036 for the discussion for (const [option, newValue] of unsupportedOptions) { await expect(rdctl(['set', `--${ option }=${ newValue }`])).resolves .toMatchObject({ stderr: expect.stringContaining(`Error: option --${ option } is not available on`) }); } }); }); test.describe('all server commands', () => { test.describe('complains about unrecognized/extra arguments', () => { const badArgs = ['string', 'brucebean']; for (const cmd of ['set', 'list-settings', 'shutdown']) { const args = [cmd, ...badArgs]; test(args.join(' '), async() => { const { stdout, stderr, error } = await rdctl(args); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining(`Error: unknown command "string" for "rdctl ${ cmd }"`), stdout: '', }); expect(stderr).toContain('Usage:'); }); } }); test.describe('complains when unrecognized options are given', () => { for (const cmd of ['set', 'list-settings', 'shutdown']) { const args = [cmd, '--Awop-bop-a-loo-mop', 'zips', '--alop-bom-bom=cows']; test(args.join(' '), async() => { const { stdout, stderr, error } = await rdctl(args); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining(`Error: unknown flag: ${ args[1] }`), stdout: '', }); expect(stderr).toContain('Usage:'); }); } }); }); test.describe('api', () => { test.describe('all subcommands', () => { test('complains when no args are given', async() => { const { stdout, stderr, error } = await rdctl(['api']); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining('Error: api command: no endpoint specified'), stdout: '', }); expect(stderr).toContain('Usage:'); }); test('empty string endpoint should give an error message', async() => { const { stdout, stderr, error } = await rdctl(['api', '']); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining('Error: api command: no endpoint specified'), stdout: '', }); expect(stderr).toContain('Usage:'); }); test('complains when more than one endpoint is given', async() => { const endpoints = ['settings', '/v1/settings']; const { stdout, stderr, error } = await rdctl(['api', ...endpoints]); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining(`Error: api command: too many endpoints specified ([${ endpoints.join(' ') }]); exactly one must be specified`), stdout: '', }); expect(stderr).toContain('Usage:'); }); }); test.describe('settings', () => { test.describe('options:', () => { test.describe('GET', () => { for (const endpoint of ['settings', '/v1/settings']) { for (const methodSpecs of [[], ['-X', 'GET'], ['--method', 'GET']]) { const args = ['api', endpoint, ...methodSpecs]; test(args.join(' '), async() => { const { stdout, stderr, error } = await rdctl(args); expect({ stdout, stderr, error, }).toEqual({ error: undefined, stderr: '', stdout: expect.stringMatching(/{.+}/s), }); verifySettingsKeys(JSON.parse(stdout)); }); } } }); test.describe('PUT', () => { test.describe('from stdin', () => { const settingsFile = path.join(paths.config, 'settings.json'); for (const endpoint of ['settings', '/v1/settings']) { for (const methodSpec of ['-X', '--method']) { for (const inputSpec of [['--input', '-'], ['--input=-']]) { const args = ['api', endpoint, methodSpec, 'PUT', ...inputSpec]; test(args.join(' '), async() => { const { stdout, stderr, error } = await rdctlWithStdin(settingsFile, args); expect({ stdout, stderr, error, }).toEqual({ error: undefined, stderr: '', stdout: expect.not.stringContaining('apply'), }); }); } } } }); test.describe('--input', () => { const settingsFile = path.join(paths.config, 'settings.json'); for (const endpoint of ['settings', '/v1/settings']) { for (const methodSpecs of [['-X', 'PUT'], ['--method', 'PUT'], []]) { for (const inputSource of [['--input', settingsFile], [`--input=${ settingsFile }`]]) { const args = ['api', endpoint, ...methodSpecs, ...inputSource]; test(args.join(' '), async() => { const { stdout, stderr, error } = await rdctl(args); expect({ stdout, stderr, error, }).toEqual({ error: undefined, stderr: '', stdout: expect.stringContaining('no changes necessary'), }); }); } } } }); test('should complain about a "--input-" flag', async() => { const { stdout, stderr, error } = await rdctl(['api', '/settings', '-X', 'PUT', '--input-']); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining('Error: unknown flag: --input-'), stdout: '', }); expect(stderr).toContain('Usage:'); }); test.describe('from body', () => { const settingsFile = path.join(paths.config, 'settings.json'); for (const endpoint of ['settings', '/v1/settings']) { for (const methodSpecs of [[], ['-X', 'PUT'], ['--method', 'PUT']]) { for (const inputOption of ['--body', '-b']) { const args = ['api', endpoint, ...methodSpecs, inputOption]; test(args.join(' '), async() => { const settingsBody = await fs.promises.readFile(settingsFile, { encoding: 'utf-8' }); const { stdout, stderr, error } = await rdctl(args.concat(settingsBody)); expect({ stdout, stderr, error, }).toEqual({ error: undefined, stderr: '', stdout: expect.stringContaining('no changes necessary'), }); }); } } } }); test.describe('complains when body and input are both specified', () => { for (const bodyOption of ['--body', '-b']) { const args = ['api', 'settings', bodyOption, '{ "doctor": { "wu" : "tang" }}', '--input', 'mabels.farm']; test(args.join(' '), async() => { const { stdout, stderr, error } = await rdctl(args); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining('Error: api command: --body and --input options cannot both be specified'), stdout: '', }); expect(stderr).toContain('Usage:'); }); } }); test('complains when no body is provided', async() => { const { stdout, stderr, error } = await rdctl(['api', 'settings', '-X', 'PUT']); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining('no settings specified in the request'), stdout: expect.stringMatching(/{.*}/s), }); expect(JSON.parse(stdout)).toEqual({ message: '400 Bad Request' } ); expect(stderr).not.toContain('Usage:'); }); test('invalid setting is specified', async() => { const newSettings = { version: CURRENT_SETTINGS_VERSION, containerEngine: { name: 'beefalo' } }; const { stdout, stderr, error } = await rdctl(['api', 'settings', '-b', JSON.stringify(newSettings)]); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringMatching(/errors in attempt to update settings:\s+Invalid value for "containerEngine.name": <"beefalo">; must be one of \["containerd","moby","docker"\]/), stdout: expect.stringMatching(/{.*}/s), }); expect(stderr).not.toContain('Usage:'); expect(JSON.parse(stdout)).toEqual({ message: '400 Bad Request' } ); }); }); }); }); test('complains on invalid endpoint', async() => { const endpoint = '/v99/no/such/endpoint'; const { stdout, stderr, error } = await rdctl(['api', endpoint]); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining(`Unknown command: GET ${ endpoint }`), stdout: expect.stringMatching(/{.*}/s), }); expect(JSON.parse(stdout)).toEqual({ message: '404 Not Found' }); expect(stderr).not.toContain('Usage:'); }); test('complains on invalid unversioned endpoint', async() => { const endpoint = '/v1/shazbat'; const { stdout, stderr, error } = await rdctl(['api', endpoint]); expect({ stdout, stderr, error, }).toEqual({ error: expect.any(Error), stderr: expect.stringContaining(`Unknown command: GET ${ endpoint }`), stdout: expect.stringMatching(/{".+?":".+"}/), }); expect(JSON.parse(stdout)).toEqual({ message: '404 Not Found' }); expect(stderr).not.toContain('Usage:'); }); test.describe('getting endpoints', () => { async function getEndpoints() { const apiSpecPath = path.join(import.meta.dirname, '../pkg/rancher-desktop/assets/specs/command-api.yaml'); const apiSpec = await fs.promises.readFile(apiSpecPath, 'utf-8'); const specPaths = yaml.parse(apiSpec).paths; return Object.entries>(specPaths) .flatMap(([path, data]) => Object.keys(data).map(method => [path, method])) .sort(); } test('no paths should return all supported endpoints', async() => { const { stdout, stderr } = await rdctl(['api', '/']); const endpoints = (await getEndpoints()) .map(([path, method]) => `${ method.toUpperCase() } ${ path }`); expect(stderr).toEqual(''); expect(JSON.parse(stdout).sort()).toEqual(endpoints.sort()); }); test('version-only path for v0 should return only itself', async() => { const { stdout, stderr } = await rdctl(['api', '/v0']); expect(stderr).toEqual(''); expect(JSON.parse(stdout)).toEqual([ 'GET /v0', ]); }); test('version-only path for v1 should return all endpoints in that version only', async() => { const { stdout, stderr } = await rdctl(['api', '/v1']); const endpoints = (await getEndpoints()) .filter(([path]) => path.startsWith('/v1')) .map(([path, method]) => `${ method.toUpperCase() } ${ path }`); expect(stderr).toEqual(''); expect(JSON.parse(stdout).sort()).toEqual(endpoints.sort()); }); test('/v2 should fail', async() => { const { stdout, stderr } = await rdctl(['api', '/v2']); expect({ stdout: JSON.parse(stdout), stderr: stderr.trim() }).toMatchObject({ stdout: { message: '404 Not Found' }, stderr: 'Unknown command: GET /v2' }); }); }); test.describe('diagnostics', () => { let categories: string[]; test('categories', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/diagnostic_categories']); expect(stderr).toEqual(''); categories = JSON.parse(stdout); expect(categories).toEqual(expect.arrayContaining(['Networking'])); }); test.skip('it finds the IDs for Utilities', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/diagnostic_ids?category=Utilities']); expect(stderr).toEqual(''); expect(JSON.parse(stdout)).toEqual(expect.arrayContaining(['RD_BIN_IN_BASH_PATH', 'RD_BIN_SYMLINKS'])); }); test('it finds the IDs for Networking', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/diagnostic_ids?category=Networking']); expect(stderr).toEqual(''); expect(JSON.parse(stdout)).toEqual(expect.arrayContaining(['CONNECTED_TO_INTERNET'])); }); test('it 404s for a nonexistent category', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/diagnostic_ids?category=cecinestpasuncategory']); expect({ stdout: JSON.parse(stdout), stderr: stderr.trim() }).toMatchObject({ stdout: { message: '404 Not Found' }, stderr: 'No diagnostic checks found in category cecinestpasuncategory' }); }); test('it finds a diagnostic check', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/diagnostic_checks?category=Networking&id=CONNECTED_TO_INTERNET']); expect(stderr).toEqual(''); expect(JSON.parse(stdout)).toMatchObject({ checks: [{ id: 'CONNECTED_TO_INTERNET', description: expect.stringMatching(/^The application/), mute: false, passed: expect.any(Boolean), }], }); }); test('it finds all diagnostic checks', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/diagnostic_checks']); expect(stderr).toEqual(''); expect(JSON.parse(stdout)).toMatchObject({ checks: expect.arrayContaining([ { category: 'Networking', id: 'CONNECTED_TO_INTERNET', description: expect.stringMatching(/^The application/), mute: false, fixes: [], passed: expect.any(Boolean), }, ]), }); }); test.skip('it finds all diagnostic checks for a category', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/diagnostic_checks?category=Utilities']); expect(stderr).toEqual(''); expect(JSON.parse(stdout)).toEqual({ checks: [ { category: 'Utilities', id: 'RD_BIN_IN_BASH_PATH', description: 'The ~/.rd/bin directory has not been added to the PATH, so command-line utilities are not configured in your bash shell.', mute: false, fixes: [ { description: 'You have selected manual PATH configuration. You can let Rancher Desktop automatically configure it.' }, ], }, { category: 'Utilities', id: 'RD_BIN_SYMLINKS', description: 'Are the files under ~/.docker/cli-plugins symlinks to ~/.rd/bin?', mute: false, fixes: [ { description: 'Replace existing files in ~/.rd/bin with symlinks to the application\'s internal utility directory.' }, ], }, ], }); }); test('it finds a diagnostic check by checkID', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/diagnostic_checks?id=CONNECTED_TO_INTERNET']); expect(stderr).toEqual(''); expect(JSON.parse(stdout)).toMatchObject({ checks: [ { category: 'Networking', id: 'CONNECTED_TO_INTERNET', description: expect.stringMatching(/^The application/), mute: false, }, ], }); }); test('it returns an empty array for a nonexistent category', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/diagnostic_checks?category=not*a*category']); expect({ stdout: JSON.parse(stdout), stderr } ).toMatchObject({ stdout: { checks: [] }, stderr: '' }); }); test('it returns an empty array for a nonexistent category with a valid ID', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/diagnostic_checks?category=not*a*category&id=CONNECTED_TO_INTERNET']); expect({ stdout: JSON.parse(stdout), stderr } ).toMatchObject({ stdout: { checks: [] }, stderr: '' }); }); test('it returns an empty array for a nonexistent checkID with a valid category', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/diagnostic_checks?category=Utilities&id=CONNECTED_TO_INTERNET']); expect({ stdout: JSON.parse(stdout), stderr } ).toMatchObject({ stdout: { checks: [] }, stderr: '' }); }); test('it returns an empty array for a nonexistent checkID when no category is specified', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/diagnostic_checks?&id=blip']); expect({ stdout: JSON.parse(stdout), stderr } ).toMatchObject({ stdout: { checks: [] }, stderr: '' }); }); }); test.describe('other endpoints', () => { test('it can find the about text', async() => { const { stdout, stderr } = await rdctl(['api', '/v1/about']); expect(stderr).toEqual(''); expect(stdout).toMatch(/\w+/); }); }); }); test.describe('shell', () => { test('can run echo', async() => { const { stdout, stderr, error } = await rdctl(['shell', 'echo', 'abc', 'def']); expect({ stdout, stderr, error, }).toEqual({ error: undefined, stderr: '', stdout: expect.stringContaining('abc def'), }); }); test('can run a command with a dash-option', async() => { const { stdout, stderr, error } = await rdctl(['shell', 'uname', '-a']); expect({ stdout, stderr, error, }).toEqual({ error: undefined, stderr: '', stdout: expect.stringMatching(/\S/), }); }); test('can run a shell', async() => { const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rdctl-shell-input')); const inputPath = path.join(tmpDir, 'echo.txt'); try { await fs.promises.writeFile(inputPath, 'echo orate linds chump\n'); const { stdout, stderr, error } = await rdctlWithStdin(inputPath, ['shell']); expect({ stdout, stderr, error, }).toEqual({ error: undefined, stderr: '', stdout: expect.stringContaining('orate linds chump'), }); } finally { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } }); }); }); // Where is the test that pushes a supported update, you may be wondering? // The problem with a positive test is that it needs to restart the backend. The UI disappears // but the various back-end processes, as well as playwright, are still running. // This kind of test would be better done as a standalone BAT-type test that can monitor // the processes. Meanwhile, the unit tests verify that a valid payload should lead to an update. // There's also no test checking for oversize-payload detection because when I try to create a // payload > 2000 characters I get this error: // FetchError: request to http://127.0.0.1:6107/v1/set failed, reason: socket hang up }); ================================================ FILE: e2e/start-in-background.e2e.spec.ts ================================================ import { test, expect, ElectronApplication } from '@playwright/test'; import { createDefaultSettings, startRancherDesktop, teardown, tool } from './utils/TestUtils'; /** * Using test.describe.serial make the test execute step by step, as described on each `test()` order * Playwright executes test in parallel by default and it will not work for our app backend loading process. * */ test.describe.serial('startInBackground setting', () => { test('window should appear when startInBackground is false', async({ colorScheme }, testInfo) => { createDefaultSettings({ application: { startInBackground: false } }); const logVariant = `startInBackgroundFalse`; const electronApp = await startRancherDesktop(testInfo, { logVariant }); await expect(checkWindowOpened(electronApp)).resolves.toBe(true); await teardown(electronApp, testInfo); }); test('window should not appear when startInBackground is true', async({ colorScheme }, testInfo) => { createDefaultSettings({ application: { startInBackground: true } }); const logVariant = `startInBackgroundTrue`; const electronApp = await startRancherDesktop(testInfo, { logVariant }); await expect(checkWindowOpened(electronApp)).resolves.toBe(false); await tool('rdctl', 'set', '--application.start-in-background=false'); await teardown(electronApp, testInfo); }); }); function checkWindowOpened(electronApp: ElectronApplication): Promise { const promise = new Promise((resolve) => { electronApp.on('window', () => resolve(true)); setTimeout(() => resolve(false), 10_000); }); // Check for any windows that may have been created since defining the // 'window' handler on electronApp for (const window of electronApp.windows()) { if (window.url().startsWith('app://')) { return Promise.resolve(true); } } return promise; } ================================================ FILE: e2e/startup-profiles.e2e.spec.ts ================================================ /* Copyright © 2023 SUSE LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import fs from 'fs'; import path from 'path'; import { expect, test } from '@playwright/test'; import _ from 'lodash'; import { clearSettings, clearUserProfile, testForFirstRunWindow, testForNoFirstRunWindow, testWaitForLogfile, verifyNoSystemProfile, verifySettings, verifySystemProfile, verifyUserProfile, } from './utils/ProfileUtils'; import { setUserProfile, reportAsset } from './utils/TestUtils'; import { CURRENT_SETTINGS_VERSION, Settings } from '@pkg/config/settings'; import { PathManagementStrategy } from '@pkg/integrations/pathManager'; import * as childProcess from '@pkg/utils/childProcess'; import paths from '@pkg/utils/paths'; import { RecursivePartial } from '@pkg/utils/typeUtils'; async function createInvalidDarwinUserProfile(contents: string) { const userProfilePath = path.join(paths.deploymentProfileUser, 'io.rancherdesktop.profile.defaults.plist'); await fs.promises.writeFile(userProfilePath, contents); } async function createInvalidLinuxUserProfile(contents: string) { const userProfilePath = path.join(paths.deploymentProfileUser, 'rancher-desktop.defaults.json'); await fs.promises.writeFile(userProfilePath, contents); } async function addRegistryEntry(path: string, name: string, valueType: string, value: string) { await childProcess.spawnFile('reg', ['add', path, '/v', name, '/f', '/t', valueType, '/d', value], { stdio: ['ignore', 'pipe', 'pipe'] }); } async function createDefaultUserRegistryProfileWithNonexistentFields() { let base = 'HKCU\\SOFTWARE\\Rancher Desktop\\Profile\\Defaults'; await addRegistryEntry(base, 'version', 'REG_DWORD', '10'); base += '\\fruits'; await addRegistryEntry(base, 'oranges', 'REG_DWORD', '5'); await addRegistryEntry(base, 'mangoes', 'REG_DWORD', '1'); await addRegistryEntry(base, 'citrus', 'REG_SZ', 'lemons'); } async function createDefaultUserRegistryProfileWithIncorrectTypes() { let base = 'HKCU\\SOFTWARE\\Rancher Desktop\\Profile\\Defaults'; await addRegistryEntry(base, 'version', 'REG_DWORD', '10'); base += '\\kubernetes'; await addRegistryEntry(base, 'version', 'REG_MULTI_SZ', 'strawberries\\0limes'); } async function createDefaultUserRegistryProfileWithValidDataButNoVersion() { const base = 'HKCU\\SOFTWARE\\Rancher Desktop\\Profile\\Defaults\\kubernetes'; await addRegistryEntry(base, 'version', 'REG_SZ', '1.29.0'); } async function createLockedUserRegistryProfileWithValidDataButNoVersion() { const base = 'HKCU\\SOFTWARE\\Rancher Desktop\\Profile\\Locked\\kubernetes'; await addRegistryEntry(base, 'version', 'REG_SZ', '1.29.0'); } test.describe.serial('starting up with profiles', () => { test.afterAll(async() => { await clearUserProfile(); await clearSettings(); }); test.describe.serial('profile combinations', () => { // First time we want to verify there *is* a first-run window. // There should never be a first-run window after that. let runFunc = testForFirstRunWindow; let i = 0; let numSkipped = 0; for (const settingsFunc of [clearSettings, verifySettings]) { for (const userProfileFunc of [clearUserProfile, verifyUserProfile]) { for (const systemProfileFunc of [verifyNoSystemProfile, verifySystemProfile]) { test(`Standard test ${ i }: ${ settingsFunc.name } / ${ userProfileFunc.name } / ${ systemProfileFunc.name }`, async({ colorScheme }, testInfo) => { const skipReasons = await systemProfileFunc(); if (skipReasons.length > 0) { console.log(`Skipping test (${ systemProfileFunc.name })`); numSkipped += 1; } else { await settingsFunc(); await userProfileFunc(); await runFunc(testInfo, { logVariant: `${ i }` }); runFunc = testForNoFirstRunWindow; } }); i++; } } } test('check for correct number of tests', () => { // Half the tests require a system profile, half require no system-profile, so we should always skip half of them. expect(numSkipped).toEqual(4); }); }); test.describe('problematic user profiles', () => { let skipReasons: string[]; test.beforeEach(async() => { await clearSettings(); await clearUserProfile(); skipReasons = await verifyNoSystemProfile(); }); test('nonexistent settings act like an empty default profile', async({ colorScheme }, testInfo) => { test.skip(skipReasons.length > 0, `Profile requirements for this test: ${ skipReasons.join(', ') }`); if (process.platform === 'win32') { await createDefaultUserRegistryProfileWithNonexistentFields(); } else { const s1 = { version: 10, fruits: { oranges: 5, mangoes: true, citrus: 'lemons', }, } as unknown as RecursivePartial; await setUserProfile(s1, null); } // We have a deployment with only a version field, good enough to bypass the first-run dialog. await testForNoFirstRunWindow(testInfo, { logVariant: 'nonexistent-settings' }); }); test('invalid format', async({ colorScheme }, testInfo) => { let errorMatcher: RegExp; const logVariant = 'invalid-profile-format'; const localSkipReasons = [...skipReasons]; if (process.platform === 'win32') { localSkipReasons.push(`This test doesn't make sense on Windows`); } test.skip(localSkipReasons.length > 0, `Profile requirements for this test: ${ localSkipReasons.join(', ') }`); switch (process.platform) { case 'darwin': await createInvalidDarwinUserProfile(` kubernetes version str`); errorMatcher = new RegExp(`Error loading plist file ${ path.join(paths.deploymentProfileUser, 'io.rancherdesktop.profile.defaults.plist') }.*Property List error: Encountered unexpected EOF`); break; case 'linux': await createInvalidLinuxUserProfile(`{"kubernetes":{"version":["str`); errorMatcher = new RegExp(`Error starting up: DeploymentProfileError: Error parsing deployment profile from ${ path.join(paths.deploymentProfileUser, 'rancher-desktop.defaults.json') }: SyntaxError: Unterminated string in JSON`); break; default: throw new Error(`Not expecting to handle platform ${ process.platform }`); } const windowCount = await testWaitForLogfile(testInfo, { logVariant }); const logPath = path.join(reportAsset(testInfo, 'log'), 'background.log'); const contents = await fs.promises.readFile(logPath, { encoding: 'utf-8' }); expect(windowCount).toEqual(0); expect(contents).toContain('Fatal Error:'); expect(contents).toMatch(errorMatcher); }); test('missing version', async({ colorScheme }, testInfo) => { const logVariant = 'missing-settings-version'; const versionLessSettings: RecursivePartial = { kubernetes: { enabled: true }, application: { debug: true, pathManagementStrategy: PathManagementStrategy.Manual, startInBackground: false, }, }; const settingsFullPath = path.join(paths.config, 'settings.json'); await fs.promises.mkdir(paths.config, { recursive: true }); await fs.promises.writeFile(settingsFullPath, JSON.stringify(versionLessSettings)); const windowCount = await testWaitForLogfile(testInfo, { logVariant }); const logPath = path.join(reportAsset(testInfo, 'log'), 'background.log'); const contents = await fs.promises.readFile(logPath, { encoding: 'utf-8' }); expect(windowCount).toEqual(0); const msg = `No version specified in ${ settingsFullPath }`; expect(contents).toMatch(new RegExp(`Fatal Error:.*${ _.escapeRegExp(msg) }`, 's')); }); test('wrong datatype in profile', async({ colorScheme }, testInfo) => { const logVariant = 'wrong-datatype-in-profile'; test.skip(skipReasons.length > 0, `Profile requirements for this test: ${ skipReasons.join(', ') }`); if (process.platform === 'win32') { await createDefaultUserRegistryProfileWithIncorrectTypes(); } else { const s1 = { version: 10, kubernetes: { version: ['strawberries', 'limes'] } } as unknown as RecursivePartial; await setUserProfile(s1, null); } const windowCount = await testWaitForLogfile(testInfo, { logVariant }); const logPath = path.join(reportAsset(testInfo, 'log'), 'background.log'); const contents = await fs.promises.readFile(logPath, { encoding: 'utf-8' }); expect(windowCount).toEqual(0); expect(contents).toContain('Fatal Error:'); if (process.platform === 'win32') { expect(contents).toContain(`Error for field 'HKCU\\SOFTWARE\\Rancher Desktop\\Profile\\Defaults\\kubernetes\\version'`); expect(contents).toContain(`expecting value of type string, got an array '["strawberries","limes"]'`); } else { expect(contents).toMatch(new RegExp(`Error in deployment file.*${ paths.deploymentProfileUser }.*defaults`)); expect(contents).toContain(`Error for field 'kubernetes.version':`); expect(contents).toContain(`expecting value of type string, got an array ["strawberries","limes"]`); } }); test('missing version in defaults deployment profile', async({ colorScheme }, testInfo) => { const logVariant = `missing-version-in-defaults-profile`; test.skip(skipReasons.length > 0, `Profile requirements for this test: ${ skipReasons.join(', ') }`); if (process.platform === 'win32') { await createDefaultUserRegistryProfileWithValidDataButNoVersion(); } else { await setUserProfile({ kubernetes: { enabled: false } }, null); } const windowCount = await testWaitForLogfile(testInfo, { logVariant }); const logPath = path.join(reportAsset(testInfo, 'log'), 'background.log'); const contents = await fs.promises.readFile(logPath, { encoding: 'utf-8' }); expect(windowCount).toEqual(0); expect(contents).toContain('Fatal Error:'); if (process.platform === 'win32') { expect(contents).toContain('Invalid default-deployment: no version specified at HKCU\\SOFTWARE\\Rancher Desktop\\Profile\\Defaults.'); expect(contents).toContain(`You'll need to add a version field to make it valid (current version is ${ CURRENT_SETTINGS_VERSION }).`); } else { expect(contents).toContain('Failed to load the deployment profile'); expect(contents).toMatch(/Invalid deployment file.*defaults.*: no version specified. You'll need to add a version field to make it valid/); } }); test('missing version in locked deployment profile', async({ colorScheme }, testInfo) => { const logVariant = 'missing-version-in-locked-profile'; test.skip(skipReasons.length > 0, `Profile requirements for this test: ${ skipReasons.join(', ') }`); if (process.platform === 'win32') { await createLockedUserRegistryProfileWithValidDataButNoVersion(); } else { await setUserProfile(null, { kubernetes: { enabled: false } }); } const windowCount = await testWaitForLogfile(testInfo, { logVariant }); const logPath = path.join(reportAsset(testInfo, 'log'), 'background.log'); const contents = await fs.promises.readFile(logPath, { encoding: 'utf-8' }); expect(windowCount).toEqual(0); expect(contents).toContain('Fatal Error:'); if (process.platform === 'win32') { expect(contents).toContain('Invalid locked-deployment: no version specified at HKCU\\SOFTWARE\\Rancher Desktop\\Profile\\Locked.'); expect(contents).toContain(`You'll need to add a version field to make it valid (current version is ${ CURRENT_SETTINGS_VERSION }).`); } else { expect(contents).toContain('Failed to load the deployment profile'); expect(contents).toMatch(/Invalid deployment file.*locked.*: no version specified. You'll need to add a version field to make it valid/); } }); }); }); ================================================ FILE: e2e/utils/ProfileUtils.ts ================================================ // Deployment-profile-related utilities import fs from 'fs'; import os from 'os'; import path from 'path'; import util from 'util'; import { expect, Page, TestInfo } from '@playwright/test'; import { createDefaultSettings, setUserProfile, startRancherDesktop, retry, teardown, reportAsset, startRancherDesktopOptions, } from './TestUtils'; import { NavPage } from '../pages/nav-page'; import { CURRENT_SETTINGS_VERSION } from '@pkg/config/settings'; import * as childProcess from '@pkg/utils/childProcess'; import paths from '@pkg/utils/paths'; export async function clearSettings(): Promise { const fullPath = path.join(paths.config, 'settings.json'); await fs.promises.rm(fullPath, { force: true }); } export async function clearUserProfile(): Promise { const platform = os.platform() as 'win32' | 'darwin' | 'linux'; if (platform === 'win32') { return await verifyNoRegistrySubtree('HKCU'); } const profilePaths = getDeploymentPaths(platform, paths.deploymentProfileUser); for (const fullPath of profilePaths) { await fs.promises.rm(fullPath, { force: true }); } } async function fileExists(fullPath: string): Promise { try { await fs.promises.access(fullPath); return true; } catch { } return false; } function getDeploymentBaseNames(platform: 'linux' | 'darwin'): string[] { if (platform === 'linux') { return ['rancher-desktop.defaults.json', 'rancher-desktop.locked.json']; } else if (platform === 'darwin') { return ['io.rancherdesktop.profile.defaults.plist', 'io.rancherdesktop.profile.locked.plist']; } else { throw new Error(`Unexpected platform ${ platform }`); } } function getDeploymentPaths(platform: 'linux' | 'darwin', profileDir: string): string[] { let baseNames = getDeploymentBaseNames(platform); if (platform === 'linux' && profileDir !== paths.deploymentProfileUser) { // macOS system profiles live in a shared directory and include the application name; // linux ones are in their own directory, and we need to remove the prefix. baseNames = baseNames.map(s => s.replace('rancher-desktop.', '')); } return baseNames.map(baseName => path.join(profileDir, baseName)); } async function hasSystemRegistrySubtree(): Promise { for (const profileType of ['defaults', 'locked']) { for (const variant of ['Policies\\Rancher Desktop', 'Rancher Desktop\\Profile']) { try { const { stdout } = await childProcess.spawnFile('reg', ['query', `HKLM\\SOFTWARE\\${ variant }\\${ profileType }`], { stdio: ['ignore', 'pipe', 'pipe'] }); if (stdout.length > 0) { return true; } } catch { } } } return false; } export async function verifySystemRegistrySubtree(): Promise { if (await hasSystemRegistrySubtree()) { return []; } else { return [`Need to add registry subtree "HKLM\\SOFTWARE\\Policies\\Rancher Desktop\\"`]; } } export async function verifySettings(): Promise { const fullPath = path.join(paths.config, 'settings.json'); if (!await fileExists(fullPath)) { createDefaultSettings(); } } export async function verifyNoRegistrySubtree(hive: string): Promise { for (const variant of ['Policies\\Rancher Desktop', 'Rancher Desktop\\Profile']) { const registryPath = `${ hive }\\SOFTWARE\\${ variant }`; try { const { stdout } = await childProcess.spawnFile('reg', ['query', registryPath], { stdio: ['ignore', 'pipe', 'pipe'] }); if (stdout.length === 0) { continue; } } catch { continue; } try { await childProcess.spawnFile('reg', ['delete', registryPath, '/f'], { stdio: ['ignore', 'pipe', 'pipe'] }); } catch (cause: any) { throw new Error(`Need to remove registry hive "${ registryPath }" (tried, got error ${ cause })`, { cause }); } } } export async function verifyUserProfile(): Promise { await clearUserProfile(); await setUserProfile({ version: 10 as typeof CURRENT_SETTINGS_VERSION, containerEngine: { allowedImages: { enabled: true } } }, null); } export async function verifyNoSystemProfile(): Promise { const platform = os.platform() as 'win32' | 'darwin' | 'linux'; if (platform === 'win32') { try { await verifyNoRegistrySubtree('HKLM'); return []; } catch (ex: any) { return [ex.message]; } } const existingProfiles = []; const profilePaths = [ ...getDeploymentPaths(platform, paths.deploymentProfileSystem), ...getDeploymentPaths(platform, paths.altDeploymentProfileSystem), ]; for (const profilePath of profilePaths) { if (await fileExists(profilePath)) { existingProfiles.push(`Need to delete system profile ${ profilePath }`); } } return existingProfiles; } export async function verifySystemProfile(): Promise { const platform = os.platform() as 'win32' | 'darwin' | 'linux'; if (platform === 'win32') { return await verifySystemRegistrySubtree(); } const profilePaths = [ ...getDeploymentPaths(platform, paths.deploymentProfileSystem), ...getDeploymentPaths(platform, paths.altDeploymentProfileSystem), ]; for (const profilePath of profilePaths) { if (await fileExists(profilePath)) { return []; } } return [`Need to create system profile file ${ profilePaths.join(' and/or ') }`]; } /** * Start Rancher Desktop, expecting a first run window to show up; accept it, * then wait for the main window to open. */ export async function testForFirstRunWindow(testInfo: TestInfo, options: startRancherDesktopOptions) { let page: Page | undefined; let navPage: NavPage; let windowCount = 0; let windowCountForMainPage = 0; const electronApp = await startRancherDesktop(testInfo, { ...options, mock: false, noModalDialogs: false, timeout: 60_000, }); electronApp.on('window', async(openedPage: Page) => { windowCount += 1; if (windowCount === 1) { await retry(async() => { const button = openedPage.getByText('OK'); if (button) { await button.click({ timeout: 10_000 }); } }, { delay: 100, tries: 50 }); return; } navPage = new NavPage(openedPage); try { await retry(async() => { await expect(navPage.mainTitle).toHaveText('Welcome to Rancher Desktop by SUSE'); }); page = openedPage; windowCountForMainPage = windowCount; } catch (ex: any) { console.log(`Ignoring failed title-test: ${ ex.toString().substring(0, 10000) }`); } }); try { let iter = 0; const start = new Date().valueOf(); const limit = 900 * 1_000 + start; // eslint-disable-next-line no-unmodified-loop-condition while (page === undefined) { const now = new Date().valueOf(); iter += 1; if (iter % 100 === 0) { console.log(`waiting for main window, iter ${ iter }...`); } if (now > limit) { throw new Error(`timed out waiting for ${ limit / 1000 } seconds`); } await util.promisify(setTimeout)(100); } expect(windowCountForMainPage).toEqual(2); } finally { await teardown(electronApp, testInfo); } } /** * Start Rancher Desktop, checking that there was no first run window (and that * the first window to appear is the main window). */ export async function testForNoFirstRunWindow(testInfo: TestInfo, options: startRancherDesktopOptions) { let page: Page | undefined; let navPage: NavPage; let windowCount = 0; let windowCountForMainPage = 0; const electronApp = await startRancherDesktop(testInfo, { ...options, mock: false, noModalDialogs: false, timeout: 60_000, }); electronApp.on('window', async(openedPage: Page) => { windowCount += 1; navPage = new NavPage(openedPage); await expect(async() => { await expect(navPage.mainTitle).toHaveText('Welcome to Rancher Desktop by SUSE'); }).toPass({ timeout: 60_000 }); page = openedPage; windowCountForMainPage = windowCount; }); try { let iter = 0; const start = new Date().valueOf(); const limit = 900 * 1_000 + start; // eslint-disable-next-line no-unmodified-loop-condition while (page === undefined) { const now = new Date().valueOf(); iter += 1; if (iter % 100 === 0) { console.log(`waiting for main window, iter ${ iter }...`); } if (now > limit) { throw new Error(`timed out waiting for ${ limit / 1000 } seconds`); } await util.promisify(setTimeout)(100); } expect(windowCountForMainPage).toEqual(1); } finally { await teardown(electronApp, testInfo); } } /** * Start Rancher Desktop, and wait for background.log file to be populated; there * should be no windows visible. */ export async function testWaitForLogfile(testInfo: TestInfo, options: startRancherDesktopOptions) { let windowCount = 0; const electronApp = await startRancherDesktop(testInfo, { ...options, mock: false, noModalDialogs: true, timeout: 60_000, }); const logPath = path.join(reportAsset(testInfo, 'log'), 'background.log'); electronApp.on('window', () => { windowCount += 1; console.log('There should be no windows for this test.'); }); try { let iter = 0; const start = new Date().valueOf(); const limit = 900 * 1_000 + start; while (true) { const now = new Date().valueOf(); iter += 1; if (iter % 100 === 0) { console.log(`waiting for logs, iter ${ iter }...`); } try { const statInfo = await fs.promises.lstat(logPath); if (statInfo && statInfo.size > 160) { break; } } catch {} if (now > limit) { throw new Error(`timed out waiting for ${ limit / 1000 } seconds`); } if (windowCount > 0) { break; } await util.promisify(setTimeout)(100); } } finally { try { // Race condition: the app might have already shut down due to the fatal profile error. await teardown(electronApp, testInfo); } catch { } } return windowCount; } ================================================ FILE: e2e/utils/TestUtils.ts ================================================ /** * TestUtils exports functions required for the E2E test specs. */ import fs from 'fs'; import os from 'os'; import path from 'path'; import util from 'util'; import { expect, _electron, ElectronApplication, TestInfo } from '@playwright/test'; import _, { GetFieldType } from 'lodash'; import { Page } from 'playwright-core'; import plist from 'plist'; import { defaultSettings, LockedSettingsType, Settings } from '@pkg/config/settings'; import { getDefaultMemory } from '@pkg/config/settingsImpl'; import { PathManagementStrategy } from '@pkg/integrations/pathManager'; import * as childProcess from '@pkg/utils/childProcess'; import paths from '@pkg/utils/paths'; import { RecursivePartial, RecursiveTypes } from '@pkg/utils/typeUtils'; let currentTest: undefined | { file: string, startTime: number, options: startRancherDesktopOptions, }; /** * Remove any existing user profiles, and set it to the given settings. If * either is `null`, then it is not re-added. */ export async function setUserProfile(userProfile: RecursivePartial | null, lockedFields:LockedSettingsType | null) { const platform = os.platform() as 'win32' | 'darwin' | 'linux'; if (platform === 'win32') { return await setWindowsUserLegacyProfile(userProfile, lockedFields); } else if (platform === 'linux') { return await setLinuxUserProfile(userProfile, lockedFields); } else { return await setDarwinUserProfile(userProfile, lockedFields); } } async function setLinuxUserProfile(userProfile: RecursivePartial | null, lockedFields:LockedSettingsType | null) { const userProfilePath = path.join(paths.deploymentProfileUser, 'rancher-desktop.defaults.json'); const userLocksPath = path.join(paths.deploymentProfileUser, 'rancher-desktop.locked.json'); if (userProfile && Object.keys(userProfile).length > 0) { await fs.promises.writeFile(userProfilePath, JSON.stringify(userProfile, undefined, 2)); } else { await fs.promises.rm(userProfilePath, { force: true }); } if (lockedFields && Object.keys(lockedFields).length > 0) { await fs.promises.writeFile(userLocksPath, JSON.stringify(lockedFields, undefined, 2)); } else { await fs.promises.rm(userLocksPath, { force: true }); } } function convertToRegistryLegacy(s: string) { return s.replace(/Policies\\Rancher Desktop/g, 'Rancher Desktop\\Profile') .replace('SOFTWARE\\Policies]', 'SOFTWARE\\Rancher Desktop]'); } async function setWindowsUserLegacyProfile(userProfile: RecursivePartial | null, lockedFields:LockedSettingsType | null) { const workdir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-test-profiles')); try { for (const [registryType, settings] of [['defaults', userProfile], ['locked', lockedFields]] as const) { // Always remove existing profiles, since we never want to merge any // existing profiles with the new ones. try { const keyPath = `HKCU\\SOFTWARE\\Rancher Desktop\\Profile\\${ registryType }`; await childProcess.spawnFile('reg.exe', ['DELETE', keyPath, '/f'], { stdio: 'pipe' }); } catch (cause: any) { if (!/unable to find/.test(Object(cause).stderr ?? '')) { throw new Error(`Error trying to delete a user registry hive: ${ cause }`, { cause }); } } if (settings && Object.keys(settings).length > 0) { const genResult = convertToRegistryLegacy(await tool('rdctl', 'create-profile', '--body', JSON.stringify(settings), '--output=reg', '--hive=hkcu', `--type=${ registryType }`)); const regFile = path.join(workdir, 'test.reg'); try { await fs.promises.writeFile(regFile, genResult); await childProcess.spawnFile('reg.exe', ['IMPORT', regFile], { stdio: 'ignore' }); } catch (cause: any) { throw new Error(`Error trying to create a user registry hive: ${ cause }`, { cause }); } } } } finally { await fs.promises.rm(workdir, { recursive: true, force: true }); } } async function setDarwinUserProfile(userProfile: RecursivePartial | null, lockedFields:LockedSettingsType | null) { const userProfilePath = path.join(paths.deploymentProfileUser, 'io.rancherdesktop.profile.defaults.plist'); const userLocksPath = path.join(paths.deploymentProfileUser, 'io.rancherdesktop.profile.locked.plist'); if (userProfile && Object.keys(userProfile).length > 0) { // plist.build() seems to have issues with RecursivePartial>, hence cast. await fs.promises.writeFile(userProfilePath, plist.build(userProfile as any)); } else { await fs.promises.rm(userProfilePath, { force: true }); } if (lockedFields && Object.keys(lockedFields).length > 0) { await fs.promises.writeFile(userLocksPath, plist.build(lockedFields)); } else { await fs.promises.rm(userLocksPath, { force: true }); } } /** * Create empty default settings to bypass gracefully * FirstPage window. */ export function createDefaultSettings(overrides: RecursivePartial = {}) { const defaultOverrides: RecursivePartial = { kubernetes: { enabled: true }, application: { debug: true, pathManagementStrategy: PathManagementStrategy.Manual, startInBackground: false, }, virtualMachine: { memoryInGB: getDefaultMemory() }, }; const settingsData: Settings = _.merge({}, defaultSettings, defaultOverrides, overrides); const settingsJson = JSON.stringify(settingsData); const fileSettingsName = 'settings.json'; const settingsFullPath = path.join(paths.config, fileSettingsName); if (!fs.existsSync(settingsFullPath)) { fs.mkdirSync(paths.config, { recursive: true }); fs.writeFileSync(path.join(paths.config, fileSettingsName), settingsJson); console.log(`Default settings file successfully created at ${ paths.config }/${ fileSettingsName }`); } else { try { const contents = fs.readFileSync(settingsFullPath, { encoding: 'utf-8' }); const settings: Settings = JSON.parse(contents.toString()); const desiredSettings: Settings = _.merge({}, settings, defaultOverrides, overrides); if (!_.eq(settings, desiredSettings)) { fs.writeFileSync(settingsFullPath, JSON.stringify(desiredSettings), { encoding: 'utf-8' }); } } catch (err) { console.log(`Failed to process ${ settingsFullPath }: ${ err }`); } } } /** * getAlternateSetting returns the setting that isn't the same as the existing setting. */ export function getAlternateSetting>(currentSettings: Settings, setting: K, altOne: GetFieldType, altTwo: GetFieldType) { return _.get(currentSettings, setting) === altOne ? altTwo : altOne; } /** * Calculate the path of an asset that should be attached to a test run. * @param type What kind of asset this is; defaults to `trace`. */ export function reportAsset(testInfo: TestInfo, type: 'trace' | 'log' = 'trace') { const testName = testInfo.file; let name = `${ path.basename(testName).replace(/(?:\.e2e)(?:\.spec)(?:\.ts)$/, '') }-`; if (currentTest?.options?.logVariant) { name += `${ currentTest.options.logVariant }-`; } if (testInfo.retry) { name += `try-${ testInfo.retry }-`; } name += { trace: 'pw-trace.zip', log: 'logs', }[type]; return path.join(import.meta.dirname, '..', 'reports', name); } /** * Tear down the application, without managing logging. This should only be * used when doing atypical tests that need to restart the application within * the test. This is normally used instead of `app.close()`. * * @note teardown() should be used where possible. */ export async function teardownApp(app: ElectronApplication) { const proc = app.process(); const pid = proc.pid; try { // Allow one minute for shutdown await Promise.race([ app.close(), util.promisify(setTimeout)(60 * 1000), ]); await tool('rdctl', 'shutdown'); } finally { if (proc.kill('SIGTERM') || proc.kill('SIGKILL')) { console.log(`Manually stopped process ${ pid }`); } // Try to do platform-specific killing based on process groups if (process.platform === 'darwin' || process.platform === 'linux') { // Send SIGTERM to the process group, wait three seconds, then send // SIGKILL and wait for one more second. for (const [signal, timeout] of [['TERM', 3_000], ['KILL', 1_000]] as const) { let pids: string[]; try { const args = ['-o', 'pid=', process.platform === 'darwin' ? '-g' : '--sid', `${ pid }`]; const { stdout } = await childProcess.spawnFile('ps', args, { stdio: ['ignore', 'pipe', 'inherit'] }); pids = stdout.trim().split(/\s+/); } catch (ex) { console.log(`Did not find processes in process group ${ pid }, ignoring.`); break; } try { if (pids.length > 0) { console.log(`Manually killing group processes ${ pids.join(' ') }`); await childProcess.spawnFile('kill', ['-s', signal, ...pids]); } } catch (ex) { console.log(`Failed to process group: ${ ex } (retrying)`); } await util.promisify(setTimeout)(timeout); } } } } export async function teardown(app: ElectronApplication, testInfo: TestInfo) { const context = app.context(); const { file: filename } = testInfo; await context.tracing.stop({ path: reportAsset(testInfo) }); await teardownApp(app); if (currentTest?.file === filename) { const delta = (Date.now() - currentTest.startTime) / 1_000; const min = Math.floor(delta / 60); const sec = Math.round(delta % 60); const string = min ? `${ min } min ${ sec } sec` : `${ sec } seconds`; console.log(`Test ${ path.basename(filename) } took ${ string }.`); } else { console.log(`Test ${ path.basename(filename) } did not have a start time.`); } } export function getResourceBinDir(): string { const srcDir = path.dirname(import.meta.dirname); return path.join(srcDir, '..', 'resources', os.platform(), 'bin'); } export function getFullPathForTool(tool: string): string { const filename = os.platform().startsWith('win') ? `${ tool }.exe` : tool; return path.join(getResourceBinDir(), filename); } /** * Run the given tool with the given arguments, returning its standard output. */ export async function tool(tool: string, ...args: string[]): Promise { const exe = getFullPathForTool(tool); try { const { stdout } = await childProcess.spawnFile(exe, args, { env: { ...process.env, PATH: `${ process.env.PATH }${ path.delimiter }${ getResourceBinDir() }`, }, stdio: ['ignore', 'pipe', 'pipe'], }); return stdout; } catch (ex:any) { console.error(`Error running ${ tool } ${ args.join(' ') }`); console.error(`stdout: ${ ex.stdout }`); console.error(`stderr: ${ ex.stderr }`); // This expect(...).toBeUndefined() will always fail; we just want to make // playwright print out the stdout and stderr along with the message. // Normally, it would just print out `ex.toString()`, which mostly just says // " exited with code 1" and doesn't explain _why_ that happened. expect({ stdout: ex.stdout, stderr: ex.stderr, message: ex.toString(), }).toBeUndefined(); throw ex; } } /** * Run `kubectl` with given arguments. * @returns standard output of the command. * @example await kubectl('version') */ export async function kubectl(...args: string[] ): Promise { return await tool('kubectl', '--context', 'rancher-desktop', ...args); } /** * Run `helm` with given arguments. * @returns standard output of the command. * @example await helm('version') */ export async function helm(...args: string[] ): Promise { return await tool('helm', '--kube-context', 'rancher-desktop', ...args); } export async function retry(proc: () => Promise, options?: { delay?: number, tries?: number }): Promise { const delay = options?.delay ?? 500; const tries = options?.tries ?? 30; for (let i = 1; ; ++i) { try { return await proc(); } catch (ex) { if (i >= tries) { console.log(`${ tries } tries exceeding, failing.`); throw ex; } console.error(`${ ex }, retrying... (${ i }/${ tries })`); await util.promisify(setTimeout)(delay); } } } export interface startRancherDesktopOptions { /** Whether to use the mock backend; defaults to true. */ mock?: boolean; /** The environment to use. */ env?: Record; /** Set to false if we want to see the first-run dialog (defaults to true). */ noModalDialogs?: boolean; /** Maximum time in milliseconds to wait for the app to launch. */ timeout?: number; /** A suffix to be added to the log file, for variants. */ logVariant?: string; } /** * Run Rancher Desktop; return promise that resolves to commonly-used * playwright objects when it has started. * @param testPath The path to the test file. * @param options Additional options; see type definition for details. */ export async function startRancherDesktop(testInfo: TestInfo, options: startRancherDesktopOptions = {}): Promise { currentTest = { file: testInfo.file, options, startTime: Date.now(), }; const { default: packageMeta } = await import('../../package.json', { with: { type: 'json' } }); const args = [ path.join(import.meta.dirname, '../..', packageMeta.main), '--disable-gpu', '--whitelisted-ips=', // See pkg/rancher-desktop/utils/commandLine.ts before changing the next item as the final option. '--disable-dev-shm-usage', ]; const logsDir = reportAsset(testInfo, 'log'); await fs.promises.rm(logsDir, { recursive: true, force: true, maxRetries: 3, }); const launchOptions: Parameters[0] = { args, env: { ...process.env, ...options?.env ?? {}, RD_LOGS_DIR: logsDir, ...options?.mock ?? true ? { RD_MOCK_BACKEND: '1' } : {}, }, }; if (options?.noModalDialogs ?? true) { args.push('--no-modal-dialogs'); } if (options?.timeout) { launchOptions.timeout = options?.timeout; } const electronApp = await _electron.launch(launchOptions); await electronApp.context().tracing.start({ screenshots: true, snapshots: true }); return electronApp; } export async function startSlowerDesktop(testInfo: TestInfo, defaultSettings: RecursivePartial = {}): Promise<[ElectronApplication, Page]> { const launchOptions: startRancherDesktopOptions = { mock: false }; createDefaultSettings(defaultSettings); if (process.env.CI) { launchOptions.timeout = 120_000; // default is 30_000 msec but the CI is very slow } const electronApp = await startRancherDesktop(testInfo, launchOptions); const page = await electronApp.firstWindow(); return [electronApp, page]; } ================================================ FILE: e2e/volumes.e2e.spec.ts ================================================ import { ElectronApplication, Page, expect, test } from '@playwright/test'; import { NavPage } from './pages/nav-page'; import { VolumesPage } from './pages/volumes-page'; import { startSlowerDesktop, teardown, tool } from './utils/TestUtils'; import { ContainerEngine } from '@pkg/config/settings'; let page: Page; test.describe.serial('Volumes Tests', () => { let electronApp: ElectronApplication; let testVolumeName: string; test.beforeAll(async({ colorScheme }, testInfo) => { [electronApp, page] = await startSlowerDesktop(testInfo, { kubernetes: { enabled: false }, containerEngine: { name: ContainerEngine.MOBY, allowedImages: { enabled: false } }, }); const navPage = new NavPage(page); await navPage.progressBecomesReady(); }); test.afterAll(async({ colorScheme }, testInfo) => { if (testVolumeName) { try { await tool('docker', 'volume', 'rm', testVolumeName); } catch (error) {} } await teardown(electronApp, testInfo); }); test('should navigate to volumes page', async() => { const navPage = new NavPage(page); const volumesPage = await navPage.navigateTo('Volumes'); await expect(navPage.mainTitle).toHaveText('Volumes'); await volumesPage.waitForTableToLoad(); }); test('should display volume in the list', async() => { const volumesPage = new VolumesPage(page); testVolumeName = `test-volume-${ Date.now() }`; try { await tool('docker', 'volume', 'create', testVolumeName); } catch (error) { console.error('Failed to create test volume:', error); throw error; } await page.reload(); await volumesPage.waitForTableToLoad(); await volumesPage.waitForVolumeToAppear(testVolumeName); }); test('should show volume information', async() => { const volumesPage = new VolumesPage(page); await volumesPage.waitForVolumeToAppear(testVolumeName); const volumeInfo = volumesPage.getVolumeInfo(testVolumeName); await expect(volumeInfo.name).not.toBeEmpty(); await expect(volumeInfo.driver).not.toBeEmpty(); await expect(volumeInfo.mountpoint).not.toBeEmpty(); }); test('should browse volume files', async() => { const volumesPage = new VolumesPage(page); await volumesPage.browseVolumeFiles(testVolumeName); await page.waitForURL(`**/volumes/files/${ testVolumeName }`, { timeout: 10_000, }); await page.goBack(); await volumesPage.waitForTableToLoad(); }); test('should delete volume', async() => { const volumesPage = new VolumesPage(page); await volumesPage.waitForVolumeToAppear(testVolumeName); await expect(volumesPage.errorBanner).toBeHidden(); await page.waitForFunction(async() => { return (await window.ddClient.docker.listContainers({ all: true })).length === 0; }); await volumesPage.deleteVolume(testVolumeName); await expect(volumesPage.errorBanner).toBeHidden(); await expect(volumesPage.getVolumeRow(testVolumeName)).toBeHidden({ timeout: 20_000, }); testVolumeName = ''; }); test('should create multiple volumes for bulk operations', async() => { const volumeNames = [ `test-bulk-volume-1-${ Date.now() }`, `test-bulk-volume-2-${ Date.now() }`, `test-bulk-volume-3-${ Date.now() }`, ]; try { for (const volumeName of volumeNames) { await tool('docker', 'volume', 'create', volumeName); } await page.reload(); const volumesPage = new VolumesPage(page); await volumesPage.waitForTableToLoad(); await expect(volumesPage.errorBanner).toBeHidden(); for (const volumeName of volumeNames) { await volumesPage.waitForVolumeToAppear(volumeName); } await volumesPage.deleteBulkVolumes(volumeNames); await expect(volumesPage.errorBanner).toBeHidden(); for (const volumeName of volumeNames) { await expect(volumesPage.getVolumeRow(volumeName)).toBeHidden({ timeout: 10_000, }); } await page.reload(); await volumesPage.waitForTableToLoad(); for (const volumeName of volumeNames) { await expect(volumesPage.getVolumeRow(volumeName)).toBeHidden(); } await expect(volumesPage.errorBanner).toBeHidden(); } catch (error) { for (const volumeName of volumeNames) { try { await tool('docker', 'volume', 'rm', volumeName); } catch (cleanupError) {} } throw error; } }); test('should handle search functionality', async() => { const volumesPage = new VolumesPage(page); const searchVolumeName = `search-test-volume-${ Date.now() }`; try { await tool('docker', 'volume', 'create', searchVolumeName); await page.reload(); await volumesPage.waitForTableToLoad(); await volumesPage.waitForVolumeToAppear(searchVolumeName); await volumesPage.searchVolumes('search-test'); await expect(volumesPage.getVolumeRow(searchVolumeName)).toBeVisible(); const isPresent = await volumesPage.isVolumePresent(searchVolumeName); expect(isPresent).toBe(true); await volumesPage.searchVolumes(''); } finally { try { await tool('docker', 'volume', 'rm', searchVolumeName); } catch (cleanupError) {} } }); test('should display error message in banner', async() => { const volumesPage = new VolumesPage(page); const volumeName = `test-volume-in-use-${ Date.now() }`; const containerName = `test-container-${ Date.now() }`; try { await tool('docker', 'volume', 'create', volumeName); // Create container that uses volume above await tool( 'docker', 'run', '--detach', '--name', containerName, '-v', `${ volumeName }:/data`, 'alpine', 'sleep', 'inf', ); await page.reload(); await volumesPage.waitForTableToLoad(); await volumesPage.waitForVolumeToAppear(volumeName); // Try to delete volume, results in error await volumesPage.deleteVolume(volumeName); await expect(volumesPage.errorBanner).toBeVisible(); await expect(volumesPage.errorBanner).toContainText(/volume is in use/i); await expect(volumesPage.getVolumeRow(volumeName)).toBeVisible(); } finally { try { await tool('docker', 'rm', '-f', containerName); await tool('docker', 'volume', 'rm', volumeName); } catch (cleanupError) {} } }); test('should auto-refresh volumes list', async() => { const volumesPage = new VolumesPage(page); const autoRefreshVolumeName = `auto-refresh-test-${ Date.now() }`; try { await volumesPage.waitForTableToLoad(); // Remove all existing volumes to ensure clean state try { const existingVolumes = await tool('docker', 'volume', 'ls', '--quiet'); const volumeNames = existingVolumes.trim().split(/\s+/); if (volumeNames.length > 0) { await tool('docker', 'volume', 'rm', '--force', ...volumeNames); } } catch {} await expect(volumesPage.volumes).toHaveCount(0); await tool('docker', 'volume', 'create', autoRefreshVolumeName); await expect(volumesPage.getVolumeRow(autoRefreshVolumeName)).toBeVisible(); const volumeInfo = volumesPage.getVolumeInfo(autoRefreshVolumeName); await expect(volumeInfo.name).not.toBeEmpty(); await expect(volumeInfo.driver).not.toBeEmpty(); await tool('docker', 'volume', 'rm', autoRefreshVolumeName); await expect( volumesPage.getVolumeRow(autoRefreshVolumeName), ).toBeHidden(); } finally { try { await tool('docker', 'volume', 'rm', autoRefreshVolumeName); } catch {} } }); }); ================================================ FILE: e2e/wsl-integrations.e2e.spec.ts ================================================ /** * This tests WSL integrations; it is a Windows-only test. */ import fs from 'fs'; import os from 'os'; import path from 'path'; import { expect, test } from '@playwright/test'; import { NavPage } from './pages/nav-page'; import { PreferencesPage } from './pages/preferences'; import { createDefaultSettings, retry, startRancherDesktop, teardown } from './utils/TestUtils'; import { spawnFile } from '@pkg/utils/childProcess'; import type { ElectronApplication, Page } from '@playwright/test'; test.describe('WSL Integrations', () => { test.describe.configure({ mode: 'serial' }); if (os.platform() !== 'win32') { test.skip(); } /** The directory containing our mock wsl.exe */ let workdir = ''; /** The environment variables, before our tests. */ let electronApp: ElectronApplication; let page: Page; let preferencesWindow: Page; test.beforeAll(async() => { const stubDir = path.resolve(import.meta.dirname, '..', 'src', 'go', 'mock-wsl'); workdir = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'rd-test-wsl-integration-')); await fs.promises.mkdir(path.join(workdir, 'system32')); await spawnFile('go', ['build', '-o', path.join(workdir, 'system32', 'wsl.exe'), '.'], { stdio: 'inherit', cwd: stubDir, env: { ...process.env, CGO_ENABLED: '1', }, }); }); const writeConfig = async(opts?: Partial>) => { const config: { commands: { args: string[], mode?: string, stdout?: string, stderr?: string, utf16le?: boolean, }[] } = { commands: [ { args: ['--list', '--quiet'], mode: 'repeated', stdout: ['alpha', 'beta', 'gamma'].join('\n'), utf16le: true, }, { args: ['--list', '--verbose'], mode: 'repeated', stdout: [ ' NAME STATE VERSION', ' alpha Stopped 2', ' beta Stopped 2', ' gamma Stopped 2', '', ].join('\n'), utf16le: true, }, ...['alpha', 'beta', 'gamma'].flatMap(distro => [ ...[['bin', 'docker-compose'], ['internal', 'wsl-helper']].flatMap(tool => ([ { args: ['--distribution', distro, '--exec', '/bin/wslpath', '-a', '-u', path.join(process.cwd(), 'resources', 'linux', ...tool)], mode: 'repeated', stdout: `/${ distro }/${ tool.join('/') }`, }])), ...[['bin', 'docker-buildx'], ['internal', 'wsl-helper']].flatMap(tool => ([ { args: ['--distribution', distro, '--exec', '/bin/wslpath', '-a', '-u', path.join(process.cwd(), 'resources', 'linux', ...tool)], mode: 'repeated', stdout: `/${ distro }/${ tool.join('/') }`, }])), ...[ [`/${ distro }/internal/wsl-helper`, 'kubeconfig', '--enable=false'], [`/${ distro }/internal/wsl-helper`, 'kubeconfig', '--enable=true'], ['/bin/sh', '-c', 'mkdir -p "$HOME/.docker/cli-plugins"'], ['/bin/sh', '-c', `if [ ! -e "$HOME/.docker/cli-plugins/docker-compose" -a ! -L "$HOME/.docker/cli-plugins/docker-compose" ] ; then ln -s "/${ distro }/bin/docker-compose" "$HOME/.docker/cli-plugins/docker-compose" ; fi`.replace(/\s+/g, ' ')], ['/bin/sh', '-c', 'mkdir -p "$HOME/.docker/cli-plugins"'], ].map(cmd => ({ args: ['--distribution', distro, '--exec', ...cmd], mode: 'repeated', })), { args: ['--distribution', distro, '--exec', '/bin/sh', '-c', 'readlink -f "$HOME/.docker/cli-plugins/docker-buildx"'], mode: 'repeated', stdout: '/dev/null', }, { args: ['--distribution', distro, '--exec', '/bin/sh', '-c', 'readlink -f "$HOME/.docker/cli-plugins/docker-compose"'], mode: 'repeated', stdout: '/dev/null', }, { args: ['--distribution', distro, '--user', 'root', '--exec', `/${ distro }/internal/wsl-helper`, 'docker-proxy', 'serve', '--verbose'], mode: 'repeated', stdout: '/dev/null', }, { args: ['--distribution', distro, '--user', 'root', '--exec', `/${ distro }/internal/wsl-helper`, 'docker-proxy', 'kill', '--verbose'], mode: 'repeated', stdout: '/dev/null', }, ]), { args: ['--distribution', 'alpha', '--exec', '/alpha/internal/wsl-helper', 'kubeconfig', '--show'], mode: 'repeated', stdout: (opts?.alpha ?? false).toString(), }, { args: ['--distribution', 'beta', '--exec', '/beta/internal/wsl-helper', 'kubeconfig', '--show'], mode: 'repeated', stdout: (opts?.beta ?? true).toString(), }, { args: ['--distribution', 'gamma', '--exec', '/gamma/internal/wsl-helper', 'kubeconfig', '--show'], mode: 'repeated', stdout: (opts?.gamma ?? 'some error').toString(), }, { args: ['--distribution', 'rancher-desktop', '--exec', '/usr/local/bin/nerdctl', '--address', '/run/k3s/containerd/containerd.sock', 'namespace', 'list', '--quiet'], mode: 'repeated', stdout: 'default', }, ], }; // Sometimes trying to update this file triggers an EBUSY error, so retry it. await retry(() => { return fs.promises.writeFile(path.join(workdir, 'config.json'), JSON.stringify(config, undefined, 2)); }, { delay: 500, tries: 20 }); }; // We need the beforeAll to allow initial Electron startup. test.beforeAll(async() => await writeConfig()); test.beforeEach(async() => await writeConfig()); test.afterAll(async() => { if (workdir) { await fs.promises.rm(workdir, { recursive: true, maxRetries: 5, }); } }); test.beforeAll(async({ colorScheme }, testInfo) => { createDefaultSettings(); electronApp = await startRancherDesktop(testInfo, { env: { PATH: path.join(workdir, 'system32') + path.delimiter + process.env.PATH, RD_TEST_WSL_EXE: path.join(workdir, 'system32', 'wsl.exe'), RD_MOCK_WSL_DATA: path.join(workdir, 'config.json'), }, }); const prefWindowPromise = electronApp.waitForEvent('window', page => /preferences/i.test(page.url())); page = await electronApp.firstWindow(); await new NavPage(page).preferencesButton.click(); preferencesWindow = await prefWindowPromise; }); test.afterAll(({ colorScheme }, testInfo) => teardown(electronApp, testInfo)); test('should open preferences modal', async() => { expect(preferencesWindow).toBeDefined(); // Wait for the window to actually load (i.e. transition from // app://index.html/#/preferences to app://index.html/#/Preferences#general) await preferencesWindow.waitForURL(/Preferences#/i); }); test('should navigate to WSL and render integrations tab', async() => { const { wsl } = new PreferencesPage(preferencesWindow); await wsl.nav.click(); await expect(wsl.nav).toHaveClass('preferences-nav-item active'); await expect(wsl.tabIntegrations).toBeVisible(); }); test('should list integrations', async() => { const { wsl: wslPage } = new PreferencesPage(preferencesWindow); await wslPage.tabIntegrations.click(); await expect(wslPage.wslIntegrations).toBeVisible(); await expect(wslPage.wslIntegrations).toHaveCount(1, { timeout: 10_000 }); const wslIntegrationList = wslPage.tabIntegrations.getByTestId('wsl-integration-list'); expect(wslIntegrationList.getByText('alpha')).not.toBeNull(); expect(wslIntegrationList.getByText('beta')).not.toBeNull(); expect(wslIntegrationList.getByText('gamma')).not.toBeNull(); }); /* test('should show checkbox states', (async() => { const integrations = wslPage.wslIntegrations; const alpha = integrations.find(item => item.name === 'alpha'); const beta = wslPage.getIntegration('beta'); const gamma = wslPage.getIntegration('gamma'); await expect(alpha.locator).toHaveCount(1); await expect(alpha.checkbox).not.toBeChecked(); await expect(alpha.name).toHaveText('alpha'); await expect(alpha.error).not.toBeVisible(); await expect(beta.locator).toHaveCount(1); await expect(beta.checkbox).toBeChecked(); await expect(beta.name).toHaveText('beta'); await expect(beta.error).not.toBeVisible(); await expect(gamma.locator).toHaveCount(1); await expect(gamma.checkbox).not.toBeChecked(); await expect(gamma.name).toHaveText('gamma'); await expect(gamma.error).toHaveText('some error'); }); test('should allow enabling integration', async() => { const { wsl: wslPage } = new PreferencesPage(preferencesWindow); await wslPage.reload(); const integrations = wslPage.integrations; await expect(integrations).toHaveCount(1, { timeout: 10_000 }); const alpha = wslPage.getIntegration('alpha'); await expect(alpha.checkbox).not.toBeChecked(); await alpha.assertEnabled(); await alpha.click(); await alpha.assertDisabled(); await writeConfig({ alpha: true }); await alpha.assertEnabled(); await expect(alpha.checkbox).toBeChecked(); }); test('should allow disabling integration', async() => { await wslPage.reload(); const integrations = wslPage.integrations; await expect(integrations).toHaveCount(1, { timeout: 10_000 }); const beta = wslPage.getIntegration('beta'); await expect(beta.checkbox).toBeChecked(); await beta.assertEnabled(); await beta.click(); await beta.assertDisabled(); await writeConfig({ beta: false }); await beta.assertEnabled(); await expect(beta.checkbox).not.toBeChecked(); }); test('should update invalid reason', async() => { await wslPage.reload(); const integrations = wslPage.integrations; await expect(integrations).toHaveCount(1, { timeout: 10_000 }); const gamma = wslPage.getIntegration('gamma'); await gamma.assertDisabled(); await expect(gamma.error).toHaveText('some error'); await writeConfig({ gamma: 'some other error' }); await page.reload(); const newGamma = (await navPage.navigateTo('WSLIntegrations')).getIntegration('gamma'); await expect(newGamma.error).toHaveText('some other error'); await newGamma.assertDisabled(); }); */ }); ================================================ FILE: eslint.config.mts ================================================ import path from 'path'; import { includeIgnoreFile } from '@eslint/compat'; import eslint from '@eslint/js'; import { standardTypeChecked } from '@vue/eslint-config-standard-with-typescript'; import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'; import pluginVue from 'eslint-plugin-vue'; import globals from 'globals'; export default defineConfigWithVueTs( eslint.configs.recommended, pluginVue.configs['flat/recommended'], standardTypeChecked.map(entry => { // Avoid issues with redefining plugins: // `Config "typescript-eslint/base": Key "plugins": Cannot redefine plugin "@typescript-eslint".` if (entry.plugins) { delete entry.plugins['@typescript-eslint']; } return entry; }), vueTsConfigs.recommendedTypeChecked, vueTsConfigs.stylisticTypeChecked, includeIgnoreFile(path.resolve('.gitignore')), { name: 'rancher-desktop', languageOptions: { sourceType: 'commonjs', }, rules: { '@stylistic/comma-dangle': ['error', 'always-multiline'], '@stylistic/indent': ['warn', 2, { SwitchCase: 0 }], '@stylistic/key-spacing': ['warn', { align: { beforeColon: false, afterColon: true, on: 'value', mode: 'minimum', }, multiLine: { beforeColon: false, afterColon: true, }, }], '@stylistic/no-multi-spaces': ['error', { ignoreEOLComments: true, exceptions: { Property: true, ImportAttribute: true, TSTypeAnnotation: true } }], '@stylistic/quotes': ['warn', 'single', { avoidEscape: true, allowTemplateLiterals: true }], '@stylistic/semi': ['error', 'always', { omitLastInOneLineBlock: true, omitLastInOneLineClassBody: true, }], '@stylistic/space-before-function-paren': ['error', 'never'], '@stylistic/space-in-parens': 'off', '@stylistic/template-curly-spacing': ['error', 'always'], '@typescript-eslint/dot-notation': 'off', '@typescript-eslint/no-base-to-string': 'off', '@typescript-eslint/no-deprecated': 'error', '@typescript-eslint/no-explicit-any': 'off', // We do need `any` sometimes '@typescript-eslint/no-unused-vars': ['warn', { args: 'none', caughtErrors: 'none', ignoreRestSiblings: true, varsIgnorePattern: '^_.', }], '@typescript-eslint/only-throw-error': 'off', '@typescript-eslint/prefer-nullish-coalescing': 'off', '@typescript-eslint/prefer-promise-reject-errors': 'off', '@typescript-eslint/no-redundant-type-constituents': 'off', '@typescript-eslint/restrict-template-expressions': 'off', '@typescript-eslint/unbound-method': 'off', 'import-x/order': ['error', { alphabetize: { order: 'asc' }, groups: ['builtin', 'external', ['parent', 'sibling', 'index'], 'internal', 'object', 'type'], 'newlines-between': 'always', pathGroupsExcludedImportTypes: ['builtin', 'object'], pathGroups: [ { pattern: '@pkg/**', group: 'internal', }, ], }], 'new-cap': 'off', // This one assumes all callbacks have errors in the first argument, which isn't likely. 'n/no-callback-literal': 'off', 'no-global-assign': ['error', { exceptions: ['console'] }], 'vue/comma-dangle': ['error', 'always-multiline'], }, }, { name: 'rancher-desktop-vue', files: ['**/*.vue'], languageOptions: { sourceType: 'module', }, }, { // The `no-useless-assignment` rule is catching `export const getters = ...` for some reason. name: 'rancher-desktop-useless-assignment-in-store', files: ['pkg/rancher-desktop/store/*.ts'], rules: { 'no-useless-assignment': 'off' }, }, { // Disable TypeScript-specific rules in JavaScript files. name: 'rancher-desktop-js', files: ['**/*.js', '**/*.cjs'], rules: { '@typescript-eslint/no-require-imports': ['off'], }, }, { // Disable lints not needed in tests (mostly global imports). name: 'rancher-desktop-spec', files: ['**/*.spec.js', '**/*.spec.ts'], languageOptions: { globals: globals.jest, }, rules: { '@typescript-eslint/no-require-imports': 'off', 'import-x/first': 'off', // Often needed for mocks }, }, { // Files we imported from Rancher Dashboard. name: 'rancher-dashboard-imports', files: [ 'pkg/rancher-desktop/plugins/*.js', 'pkg/rancher-desktop/utils/*.js', ], languageOptions: { globals: globals.browser, }, }, { // Files we imported from Rancher Dashboard. name: 'rancher-dashboard-useless-assignments', files: [ 'pkg/rancher-desktop/components/SortableTable/*', 'pkg/rancher-desktop/store/*.js', 'pkg/rancher-desktop/utils/*.js', ], rules: { 'no-useless-assignment': 'off', }, }, { // Compatibility: disable lints during the ESLint transition. name: 'rancher-desktop-compatibility', extends: [ { // Files we imported from Rancher Dashboard. name: 'rancher-dashboard-imports', files: [ 'pkg/rancher-desktop/components/SortableTable/**', ], rules: { 'vue/eqeqeq': 'off', }, }, { // Files in workflows were previously excluded from linting name: 'rancher-desktop-workflows', ignores: ['.github/workflows/**'], }, ], rules: { '@typescript-eslint/class-literal-property-style': 'off', '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-floating-promises': 'off', '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/no-require-imports': 'off', '@typescript-eslint/no-unsafe-enum-comparison': 'off', '@typescript-eslint/no-unsafe-function-type': 'off', '@typescript-eslint/no-unused-expressions': 'off', '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/prefer-find': 'off', '@typescript-eslint/prefer-for-of': 'off', 'array-callback-return': 'off', 'no-constant-binary-expression': 'off', 'no-unreachable-loop': 'off', 'no-use-before-define': 'off', 'no-useless-escape': 'off', 'prefer-rest-params': 'off', 'prefer-spread': 'off', 'vue/block-lang': 'off', 'vue/component-definition-name-casing': 'off', 'vue/multi-word-component-names': 'off', 'vue/no-deprecated-delete-set': 'off', 'vue/no-reserved-component-names': 'off', 'vue/no-side-effects-in-computed-properties': 'off', 'vue/no-v-for-template-key-on-child': 'off', 'vue/no-v-html': 'off', 'vue/order-in-components': 'off', 'vue/require-explicit-emits': 'off', }, }, ); ================================================ FILE: go.work ================================================ go 1.25.0 use ( ./scripts ./src/go/docker-credential-none ./src/go/extension-proxy ./src/go/guestagent ./src/go/mock-wsl ./src/go/nerdctl-stub ./src/go/nerdctl-stub/generate ./src/go/networking ./src/go/rdctl ./src/go/spin-stub ./src/go/startup-profile ./src/go/wsl-helper ) ================================================ FILE: jest.config.js ================================================ // @ts-check import { TS_EXT_TO_TREAT_AS_ESM, ESM_TS_TRANSFORM_PATTERN } from 'ts-jest'; /** @type {import('jest').Config} */ export default { transform: { [ESM_TS_TRANSFORM_PATTERN]: ['ts-jest', { useESM: true }], '^.+\\.vue$': './pkg/rancher-desktop/utils/testUtils/vue-jest.js', }, transformIgnorePatterns: [], extensionsToTreatAsEsm: [...TS_EXT_TO_TREAT_AS_ESM, '.vue'], moduleFileExtensions: [ 'js', 'json', 'node', // For native modules, e.g. @napi-rs/xattr 'ts', 'vue', ], modulePathIgnorePatterns: [ '/dist', '/pkg/rancher-desktop/dist', '/.git', '/e2e', '/screenshots', ], moduleNameMapper: { '\\.css$': '/pkg/rancher-desktop/config/emptyStubForJSLinter.js', '^@pkg/assets/': '/pkg/rancher-desktop/config/emptyStubForJSLinter.js', '^@pkg/(.*)$': '/pkg/rancher-desktop/$1', }, setupFiles: [ '/pkg/rancher-desktop/utils/testUtils/setupVue.ts', ], testEnvironment: 'jsdom', testEnvironmentOptions: { customExportConditions: [ 'node', 'node-addons', ], }, testPathIgnorePatterns: [ '/node_modules/', '/pkg/rancher-desktop/sudo-prompt/', ], }; ================================================ FILE: package.json ================================================ { "name": "rancher-desktop", "productName": "Rancher Desktop", "license": "Apache-2.0", "version": "1.22.0", "author": { "name": "SUSE", "email": "containers@suse.com" }, "engines": { "node": "^22.14.0" }, "type": "module", "packageManager": "yarn@4.9.4", "repository": { "type": "git", "url": "https://github.com/rancher-sandbox/rancher-desktop.git" }, "scripts": { "dev": "node scripts/ts-wrapper.js scripts/dev.ts", "lint": "yarn lint:fix", "lint:fix": "yarn lint:typescript:fix && yarn lint:go:fix && yarn lint:spelling", "lint:nofix": "yarn lint:typescript:nofix && yarn lint:go:nofix && yarn lint:spelling", "lint:typescript:fix": "yarn lint:typescript:nofix --fix", "lint:typescript:nofix": "node scripts/ts-wrapper.js scripts/lint-typescript.ts", "lint:go:fix": "node scripts/ts-wrapper.js scripts/lint-go.ts --fix", "lint:go:nofix": "node scripts/ts-wrapper.js scripts/lint-go.ts", "lint:spelling": "bash scripts/spelling.sh", "generate:nerdctl-stub": "powershell -ExecutionPolicy RemoteSigned scripts/windows/generate-nerdctl-stub.ps1", "generate:extension-data": "node scripts/ts-wrapper.js scripts/extension-data.ts", "build": "node scripts/ts-wrapper.js scripts/build.ts", "package": "node scripts/ts-wrapper.js scripts/package.ts", "sign": "node scripts/ts-wrapper.js scripts/sign.ts", "wix": "node scripts/ts-wrapper.js scripts/wix.ts", "test": "yarn lint:nofix && yarn test:unit && yarn test:extra", "test:unit": "yarn test:unit:jest && yarn test:unit:nerdctl-stub && yarn test:unit:wsl-helper && yarn test:unit:rdctl", "test:unit:jest": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=1 node --experimental-vm-modules node_modules/jest/bin/jest.js", "test:unit:watch": "yarn test:unit -- --watch", "test:unit:nerdctl-stub": "cd ./src/go/nerdctl-stub/ && go test ./...", "test:unit:rdctl": "cd ./src/go/rdctl/ && go test ./...", "test:unit:wsl-helper": "cd ./src/go/wsl-helper/ && go generate ./... && go test ./...", "test:extra": "yarn test:extra:api-schema", "test:extra:api-schema": "node scripts/ts-wrapper.js scripts/check-api-schema.ts", "test:e2e": "node scripts/ts-wrapper.js scripts/e2e.ts", "test:e2e:screenshots": "node scripts/ts-wrapper.js scripts/e2e.ts --config=screenshots/playwright-config.ts", "postinstall": "node scripts/ts-wrapper.js scripts/postinstall.ts", "postuninstall": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=1 electron-builder install-app-deps", "rddepman": "node scripts/ts-wrapper.js scripts/rddepman.ts", "ucmonitor": "node scripts/ts-wrapper.js scripts/unreleased-change-monitor.ts", "dcmonitor": "node scripts/ts-wrapper.js scripts/docker-cli-monitor.ts", "screenshots": "yarn screenshots:light && yarn screenshots:dark", "screenshots:dark": "cross-env THEME=dark yarn test:e2e:screenshots", "screenshots:light": "cross-env THEME=light yarn test:e2e:screenshots" }, "main": "dist/app/background.js", "dependencies": { "@docker/extension-api-client-types": "0.4.2", "@kubernetes/client-node": "1.4.0", "@napi-rs/xattr": "^1.0.3", "@rancher/components": "0.3.0-alpha.1", "@xterm/addon-fit": "0.11.0", "@xterm/addon-search": "0.16.0", "@xterm/addon-web-links": "0.12.0", "@xterm/xterm": "6.0.0", "async-mutex": "^0.5.0", "cookie-universal": "2.2.2", "cross-spawn": "7.0.6", "dayjs": "1.11.20", "dompurify": "3.3.3", "electron-updater": "6.8.3", "express": "5.2.1", "floating-vue": "5.2.2", "fs-extra": "11.3.4", "http-proxy-middleware": "3.0.5", "intl-messageformat": "11.1.2", "jquery": "4.0.0", "jsonpath-plus": "10.4.0", "lodash": "4.17.23", "marked": "17.0.4", "native-reg": "1.1.1", "node-forge": "1.3.3", "proxy-agent": "^6.5.0", "rancher-icons": "rancher/icons#v2.0.21", "semver": "7.7.4", "tar-stream": "3.1.8", "vue": "3.5.30", "vue-3-slider-component": "1.0.2", "vue-router": "5.0.3", "vue-select": "3.20.4", "vuex": "4.1.0", "which": "6.0.1", "yaml": "2.8.2" }, "devDependencies": { "@babel/eslint-parser": "7.28.6", "@babel/plugin-proposal-class-properties": "7.18.6", "@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6", "@babel/plugin-proposal-optional-chaining": "7.21.0", "@babel/plugin-proposal-private-methods": "7.18.6", "@babel/plugin-proposal-private-property-in-object": "7.21.11", "@electron/asar": "4.1.0", "@electron/fuses": "^2.1.0", "@electron/notarize": "3.1.1", "@eslint/compat": "2.0.3", "@eslint/js": "10.0.1", "@playwright/test": "1.58.2", "@types/cross-spawn": "6.0.6", "@types/dompurify": "3.2.0", "@types/ejs": "3.1.5", "@types/jest": "30.0.0", "@types/lodash": "4.17.24", "@types/mustache": "4.2.6", "@types/node": "22.19.15", "@types/node-forge": "1.3.14", "@types/plist": "3.0.5", "@types/ps-tree": "1.1.6", "@types/semver": "7.7.1", "@types/tar-stream": "3.1.4", "@types/which": "3.0.4", "@vue/cli-plugin-babel": "5.0.9", "@vue/cli-plugin-router": "5.0.9", "@vue/cli-plugin-vuex": "5.0.9", "@vue/cli-service": "5.0.9", "@vue/compiler-sfc": "3.5.30", "@vue/eslint-config-standard-with-typescript": "9.2.0", "@vue/eslint-config-typescript": "14.7.0", "@vue/test-utils": "2.4.6", "@yarnpkg/cli": "^4.12.0", "@yarnpkg/core": "^4.5.0", "@yarnpkg/types": "^4.0.1", "babel-core": "7.0.0-bridge.0", "babel-jest": "30.3.0", "babel-loader": "10.1.1", "cross-env": "10.1.0", "css-loader": "7.1.4", "ejs": "5.0.1", "electron": "41.0.2", "electron-builder": "26.8.1", "eslint": "10.0.3", "eslint-plugin-vue": "10.8.0", "extract-zip": "2.0.1", "glob": "^13.0.3", "globals": "17.4.0", "jest": "30.3.0", "jest-environment-jsdom": "30.3.0", "js-yaml-loader": "1.2.2", "mustache": "4.2.0", "node-addon-api": "8", "node-gyp": "12.2.0", "node-gyp-build": "4.8.4", "node-loader": "^2.1.0", "octokit": "5.0.5", "plist": "3.1.0", "ps-tree": "1.2.0", "raw-loader": "4.0.2", "sass": "1.98.0", "sass-loader": "16.0.7", "ts-jest": "29.4.6", "ts-loader": "^9.5.4", "tsconfig-paths": "4.2.0", "tsx": "4.21.0", "typescript": "5.9.3", "webpack": "5.100.2" }, "dependenciesMeta": { "electron": { "built": true }, "esbuild": { "built": true }, "native-reg": { "built": true }, "unrs-resolver": { "built": true } }, "resolutions": { "string-width": "^4" }, "optionalDependencies": { "dmg-license": "1.0.11", "posix-node": "0.12.0" }, "browserslist": [ "node 22", "electron >= 35" ] } ================================================ FILE: packaging/electron-builder.yml ================================================ # copyright needs to stay in sync with message in About panel in background.ts copyright: Copyright © 2021-2026 SUSE LLC productName: Rancher Desktop icon: ./resources/icons/logo-square-512.png appId: io.rancherdesktop.app asar: true asarUnpack: - '**/*.node' electronLanguages: [ en-US ] extraResources: - resources/ - '!resources/darwin/lima*.tgz' - '!resources/darwin/qemu*.tgz' - '!resources/linux/lima*.tgz' - '!resources/linux/qemu*.tgz' - '!resources/linux/staging/' - '!resources/win32/staging/' - '!resources/host/' - '!resources/**/*.js.map' files: - dist/app/**/* - '!**/node_modules/*/prebuilds/!(${platform}*)/*.node' mac: darkModeSupport: true hardenedRuntime: true gatekeeperAssess: false icon: ./resources/icons/mac-icon.png target: [ dmg, zip ] identity: ~ # We sign in a separate step extraFiles: - build/signing-config-mac.yaml - { from: dist/electron-builder.yaml, to: electron-builder.yml } win: target: [ zip ] signtoolOptions: signingHashAlgorithms: [ sha256 ] # We only support Windows 10 + WSL2 requestedExecutionLevel: asInvoker # The _app_ doesn't need privileges extraFiles: - build/wix/* - build/license.rtf - build/signing-config-win.yaml - { from: dist/wix-custom-action.dll, to: wix-custom-action.dll } - { from: dist/electron-builder.yaml, to: electron-builder.yml } linux: category: Utility executableName: rancher-desktop artifactName: ${name}-${version}-linux.zip target: [ zip ] publish: provider: custom upgradeServer: https://desktop.version.rancher.io/v1/checkupgrade vPrefixedTagName: true ================================================ FILE: packaging/linux/appimage.yml ================================================ app: rancher-desktop build: packages: - unzip - ImageMagick - libcairo2 script: - rm -rf $BUILD_APPDIR/* && mkdir -p $BUILD_APPDIR/opt/rancher-desktop $BUILD_APPDIR/usr/share/metainfo $BUILD_APPDIR/usr/bin $BUILD_APPDIR/usr/lib64 - unzip $BUILD_SOURCE_DIR/rancher-desktop.zip -d $BUILD_APPDIR/opt/rancher-desktop - chmod 04755 $BUILD_APPDIR/opt/rancher-desktop/chrome-sandbox - mv $BUILD_APPDIR/opt/rancher-desktop/resources/resources/linux/rancher-desktop.desktop $BUILD_APPDIR - convert -resize 512x512 $BUILD_APPDIR/opt/rancher-desktop/resources/resources/icons/logo-square-512.png $BUILD_APPDIR/rancher-desktop.png - mv $BUILD_APPDIR/opt/rancher-desktop/resources/resources/linux/lima/bin/qemu-* $BUILD_APPDIR/usr/bin - mv $BUILD_APPDIR/opt/rancher-desktop/resources/resources/linux/lima/share/qemu $BUILD_APPDIR/usr/share - mv $BUILD_APPDIR/opt/rancher-desktop/resources/resources/linux/lima/lib $BUILD_APPDIR/usr - cp /usr/lib64/libcairo* $BUILD_APPDIR/usr/lib64/ - ln -s ../../opt/rancher-desktop/rancher-desktop $BUILD_APPDIR/usr/bin/rancher-desktop - ln -s ../share/qemu $BUILD_APPDIR/usr/bin/pc_bios ================================================ FILE: packaging/linux/flatpak.yaml ================================================ # This file is unused and just kept for reference for plain flatpak builds id: io.rancherdesktop.app branch: main runtime: org.freedesktop.Platform runtime-version: '21.08' sdk: org.freedesktop.Sdk base: org.electronjs.Electron2.BaseApp base-version: '21.08' sdk-extensions: # Not really needed since we are not building the app here - org.freedesktop.Sdk.Extension.node14 command: electron-wrapper separate-locales: false finish-args: - --share=ipc - --socket=x11 - --socket=wayland - --share=network - --device=dri - --device=kvm - --filesystem=xdg-config/rancher-desktop:create - --filesystem=xdg-cache/rancher-desktop:create - --filesystem=xdg-data/rancher-desktop:create - --filesystem=home - --talk-name=org.freedesktop.Notifications - --own-name=org.kde.* rename-desktop-file: rancher-desktop.desktop rename-appdata-file: rancher-desktop.appdata.xml modules: - name: rancher-desktop buildsystem: simple sources: - type: dir path: . - type: script dest-filename: electron-wrapper commands: - | export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID" zypak-wrapper /app/lib/io.rancherdesktop.app/rancher-desktop "$@" build-commands: # Bundle electron build after yarn build -- --linux dir - mkdir -p /app/lib/io.rancherdesktop.app - unzip rancher-desktop.zip -d /app/lib/io.rancherdesktop.app # Remove in app qemu binaries - rm /app/lib/io.rancherdesktop.app/lib /app/lib/io.rancherdesktop.app/pc-bios /app/lib/io.rancherdesktop.app/qemu-* -rf # Include FreeDesktop integration files at expected locations - | rm -rf /app/share/metainfo /app/share/icons /app/share/applications mkdir -p /app/share/metainfo /app/share/applications icon="/app/lib/io.rancherdesktop.app/resources/resources/icons/logo-square-512.png" for size in 512x512 256x256 128x128 96x96 64x64 48x48 32x32 24x24 16x16; do mkdir "/app/share/icons/hicolor/${size}/apps" -p ffmpeg -i "${icon}" -vf scale="${size}" "/app/share/icons/hicolor/${size}/apps/io.rancherdesktop.app.png" done mv /app/lib/io.rancherdesktop.app/resources/resources/linux/rancher-desktop.desktop /app/share/applications mv /app/lib/io.rancherdesktop.app/resources/resources/linux/rancher-desktop.appdata.xml /app/share/metainfo # Install app wrapper - install -Dm755 -t /app/bin/ electron-wrapper modules: - name: qemu config-opts: - "--disable-user" - "--disable-vnc" - "--disable-sdl" - "--disable-gtk" - "--disable-curses" - "--disable-iconv" - "--disable-gio" - "--enable-kvm" - "--target-list=x86_64-softmmu" sources: - type: archive url: https://download.qemu.org/qemu-6.1.0.tar.xz sha256: eebc089db3414bbeedf1e464beda0a7515aad30f73261abc246c9b27503a3c96 ================================================ FILE: packaging/linux/rancher-desktop.appdata.xml ================================================ rancher-desktop CC0-1.0 Apache-2.0 Rancher Desktop Kubernetes and container management on the desktop

Rancher Desktop is an open-source project to bring Kubernetes and container management to the desktop

rancher-desktop https://rancherdesktop.io/ https://github.com/rancher-sandbox/rancher-desktop/issues
================================================ FILE: packaging/linux/rancher-desktop.spec ================================================ # # spec file for package rancher-desktop # # Copyright (c) 2025 SUSE LLC # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed # upon. The license for this file, and modifications and additions to the # file, is the same license as for the pristine package itself (unless the # license for the pristine package is not an Open Source License, in which # case the license is the MIT License). An "Open Source License" is a # license that conforms to the Open Source Definition (Version 1.9) # published by the Open Source Initiative. # Please submit bugfixes or comments via https://bugs.opensuse.org/ # Name: rancher-desktop Version: 0 Release: 0 Summary: Kubernetes and container management on the desktop License: Apache-2.0 BuildRoot: %{_tmppath}/%{name}-%{version}-build Group: Development/Tools/Other Source0: %{name}.zip URL: https://github.com/rancher-sandbox/rancher-desktop#readme %if "%{_vendor}" == "debbuild" # Needed to set Maintainer in output debs Packager: SUSE %endif %if 0%{?fedora} || 0%{?rhel} %global debug_package %{nil} %endif AutoReqProv: no BuildRequires: unzip %if 0%{?debian} BuildRequires: imagemagick %else BuildRequires: ImageMagick %endif %if 0%{?debian} Requires: qemu-utils Requires: qemu-system-x86 Requires: pass Requires: openssh-client # To enumerate system certificates Requires: gnutls-bin Requires: libasound2 Requires: libatk1.0-0 Requires: libatk-bridge2.0-0 Requires: libatspi2.0-0 Requires: libc6 Requires: libcairo2 Requires: libcups2 Requires: libdbus-1-3 Requires: libdrm2 Requires: libexpat1 Requires: libgbm1 Requires: libgcc1 Requires: libgdk-pixbuf-2.0-0 Requires: libglib2.0-0 Requires: libglib2.0-dev Requires: libgtk-3-0 Requires: libnspr4 Requires: libnss3 Requires: libpango-1.0-0 Requires: libx11-6 Requires: libxcb1 Requires: libxcomposite1 Requires: libxdamage1 Requires: libxext6 Requires: libxfixes3 Requires: libxkbcommon0 Requires: libxrandr2 %else Requires: qemu Requires: openssh-clients %if 0%{?fedora} || 0%{?rhel} Requires: (pass or libsecret) %else Requires: (password-store or libsecret-1-0) Requires: qemu-img %endif Requires: glibc Requires: desktop-file-utils %if 0%{?fedora} || 0%{?rhel} Requires: libX11 Requires: libXcomposite Requires: libXdamage Requires: libXext Requires: libXfixes Requires: libXrandr Requires: alsa-lib Requires: atk Requires: at-spi2-atk Requires: at-spi2-core Requires: cairo Requires: cups-libs Requires: dbus-libs Requires: libdrm Requires: expat Requires: mesa-libgbm Requires: libgcc Requires: gdk-pixbuf2 Requires: glib # To enumerate system certificates Requires: gnutls-utils Requires: gtk3 Requires: pango Requires: libxcb Requires: libxkbcommon Requires: nspr Requires: nss %else # To enumerate system certificates Requires: gnutls Requires: libX11-6 Requires: libXcomposite1 Requires: libXdamage1 Requires: libXext6 Requires: libXfixes3 Requires: libXrandr2 Requires: libasound2 Requires: libatk-1_0-0 Requires: libatk-bridge-2_0-0 Requires: libatspi0 Requires: libcairo2 Requires: libcups2 Requires: libdbus-1-3 Requires: libdrm2 Requires: libexpat1 Requires: libgbm1 Requires: libgcc_s1 Requires: libgdk_pixbuf-2_0-0 Requires: libgio-2_0-0 Requires: libglib-2_0-0 Requires: libgmodule-2_0-0 Requires: libgobject-2_0-0 Requires: libgtk-3-0 Requires: libpango-1_0-0 Requires: libxcb1 Requires: libxkbcommon0 Requires: mozilla-nspr Requires: mozilla-nss %endif %endif %description Rancher Desktop is an open-source project to bring Kubernetes and container management to the desktop %prep %setup -c %{name} -n %{name} %build # Generate icons icon="resources/resources/icons/logo-square-512.png" for size in 512x512 256x256 128x128 96x96 64x64 48x48 32x32 24x24 16x16; do mkdir "share/icons/hicolor/${size}/apps" -p convert -resize "${size}" "${icon}" "share/icons/hicolor/${size}/apps/%{name}.png" done # Desktop integration files mkdir -p share/applications share/metainfo mv resources/resources/linux/rancher-desktop.desktop share/applications/rancher-desktop.desktop mv resources/resources/linux/rancher-desktop.appdata.xml share/metainfo/rancher-desktop.appdata.xml # Remove qemu binaries included in lima tarball rm -v resources/resources/linux/lima/bin/qemu-* rm -rvf resources/resources/linux/lima/lib rm -rvf resources/resources/linux/lima/share/qemu %install mkdir -p "%{buildroot}%{_prefix}/bin" "%{buildroot}/opt/%{name}" cp -ra ./share "%{buildroot}%{_prefix}" cp -ra ./* "%{buildroot}/opt/%{name}" # Link to the binary ln -sf "/opt/%{name}/rancher-desktop" "%{buildroot}%{_bindir}/rancher-desktop" %post # This is needed to ensure Debian packages have proper file permissions; # otherwise the postinst script is not generated correctly. true %files %defattr(-,root,root,-) %dir /opt/%{name} /opt/%{name}* %attr(4755,root,root) /opt/%{name}/chrome-sandbox %{_bindir}/rancher-desktop %{_prefix}/share/applications/rancher-desktop.desktop %{_prefix}/share/icons/hicolor/* %{_prefix}/share/metainfo/rancher-desktop.appdata.xml %changelog ================================================ FILE: pkg/rancher-desktop/assets/dependencies.yaml ================================================ lima: 1.2.1.rd2 qemu: 9.2.0.rd2 socketVMNet: 1.2.2 alpineLimaISO: isoVersion: 0.2.47.rd1 alpineVersion: 3.23.0 WSLDistro: "0.94" kuberlr: 0.6.1 helm: 4.1.3 dockerCLI: 29.3.0 dockerBuildx: 0.32.1 dockerCompose: 5.1.1 golangci-lint: 2.11.3 trivy: 0.69.3 steve: 0.1.0-beta9.1 rancherDashboard: 2.11.1.rd3 dockerProvidedCredentialHelpers: 0.9.5 ECRCredentialHelper: 0.12.0 mobyOpenAPISpec: "1.54" wix: v3.14.1 hostSwitch: 1.2.7 moproxy: 0.5.1 spinShim: 0.23.0 spinOperator: 0.6.1 certManager: 1.20.0 spinCLI: 3.6.2 spinKubePlugin: 0.4.0 check-spelling: 0.0.25 ================================================ FILE: pkg/rancher-desktop/assets/extension-data.yaml ================================================ # Data generated by running `yarn generate:extension-data`. DO NOT EDIT. - slug: rancher/application-collection-extension version: 0.5.2 containerd_compatible: true labels: com.docker.desktop.extension.api.version: 0.3.4 com.docker.desktop.extension.icon: https://raw.githubusercontent.com/rancherlabs/application-collection-extension/refs/heads/main/assets/rancher-logo-cow-blue.svg com.docker.extension.additional-urls: '[ {"title":"Product page","url":"https://www.suse.com/products/rancher/application-collection"}, {"title":"Web application","url":"https://apps.rancher.io"}, {"title":"Documentation","url":"https://docs.apps.rancher.io"}, {"title":"Support","url":"https://github.com/rancherlabs/application-collection-extension/discussions"} ]' com.docker.extension.categories: kubernetes,utility-tools com.docker.extension.changelog: See full change log. com.docker.extension.detailed-description: " Build and run cloud-native applications with SUSE's trusted, curated, and continuously updated application collection.
This extension helps you integrating the Collection into your local development environment by:
  • Managing the authentication: docker, helm and kubernetes credentials are automatically configured
  • Making apps plug&play: installs the workloads with a predefined set of values ready for local deployment
  • Helping you stay up-to-date: detects application updates and helps you through the update process

Usage:
  1. Have a target kubernetes cluster configured in your context
  2. Install the extension
  3. Generate an access token and configure the authentication
  4. Start deploying workloads
" com.docker.extension.publisher-url: https://apps.rancher.io/ com.docker.extension.screenshots: '[ {"alt":"Collection", "url":"https://raw.githubusercontent.com/rancherlabs/application-collection-extension/refs/heads/main/assets/01_collection.png"}, {"alt":"Application details", "url":"https://raw.githubusercontent.com/rancherlabs/application-collection-extension/refs/heads/main/assets/02_application-details.png"}, {"alt":"Chart values form", "url":"https://raw.githubusercontent.com/rancherlabs/application-collection-extension/refs/heads/main/assets/03_install-form.png"}, {"alt":"Workloads", "url":"https://raw.githubusercontent.com/rancherlabs/application-collection-extension/refs/heads/main/assets/04_workloads.png"}, {"alt":"Workload details", "url":"https://raw.githubusercontent.com/rancherlabs/application-collection-extension/refs/heads/main/assets/05_workload-details.png"} ]' com.suse.apps.main-package: nodejs-24 com.suse.bci.micro.authors: https://github.com/SUSE/bci/discussions com.suse.bci.micro.created: 2025-12-11T11:54:43.169877214Z com.suse.bci.micro.description: A micro environment for containers based on the SUSE Linux Enterprise Base Container Image. com.suse.bci.micro.disturl: obs://build.suse.de/SUSE:SLE-15-SP7:Update:CR/containers/a53d17097cf3a907bf42b29b24882459-micro-image com.suse.bci.micro.eula: sle-bci com.suse.bci.micro.lifecycle-url: https://www.suse.com/lifecycle#suse-linux-enterprise-server-15 com.suse.bci.micro.name: 15.7-52.6 com.suse.bci.micro.reference: registry.suse.com/bci/bci-micro:15.7-52.6 com.suse.bci.micro.release-stage: released com.suse.bci.micro.source: https://sources.suse.com/SUSE:SLE-15-SP7:Update:CR/micro-image/a53d17097cf3a907bf42b29b24882459/ com.suse.bci.micro.supportlevel: l3 com.suse.bci.micro.title: SLE BCI 15 SP7 Micro com.suse.bci.micro.until: 2031-07-31 com.suse.bci.micro.url: https://www.suse.com/products/base-container-images/ com.suse.bci.micro.vendor: SUSE LLC com.suse.bci.micro.version: 15.7-52.6 com.suse.eula: "" com.suse.lifecycle-url: "" com.suse.release-stage: "" com.suse.supportlevel: "" com.suse.supportlevel.until: "" io.artifacthub.package.logo-url: "" io.artifacthub.package.readme-url: "" org.openbuildservice.disturl: obs://build.suse.de/Devel:Orchid:Containers/containers/103eb70d1e78f2fd8ec1baa2fc66d3c2-nodejs-24 org.opencontainers.image.authors: "" org.opencontainers.image.base.digest: sha256:7d103f4317c8c7eae4d0126d34c8b7a92769b44764a526a63325f0ca24150092 org.opencontainers.image.base.name: registry.suse.com/bci/bci-micro:15.7-52.6 org.opencontainers.image.created: 2025-12-12T14:06:19.962587777Z org.opencontainers.image.description: Integrate the Application Collection into your development lifecycle org.opencontainers.image.ref.name: 24.12.0-5.27 org.opencontainers.image.source: "" org.opencontainers.image.title: SUSE Application Collection org.opencontainers.image.url: https://apps.rancher.io/applications/nodejs org.opencontainers.image.vendor: SUSE LLC org.opencontainers.image.version: 24.12.0 org.opensuse.reference: registry.suse.com/bci/bci-micro:15.7-52.6 title: SUSE Application Collection logo: https://raw.githubusercontent.com/rancherlabs/application-collection-extension/refs/heads/main/assets/rancher-logo-cow-blue.svg publisher: SUSE LLC short_description: Integrate the Application Collection into your development lifecycle - slug: ghcr.io/rancher-sandbox/rancher-desktop-rdx-ai-workbench version: 0.2.0 containerd_compatible: true labels: com.docker.desktop.extension.api.version: 0.3.4 com.docker.desktop.extension.icon: https://raw.githubusercontent.com/rancher-sandbox/rancher-desktop-rdx-ai-workbench/refs/tags/v0.2.0/workbench.svg com.docker.extension.additional-urls: "" com.docker.extension.categories: "" com.docker.extension.changelog: "" com.docker.extension.detailed-description: "" com.docker.extension.publisher-url: "" com.docker.extension.screenshots: "" org.opencontainers.image.description: AI Workbench extension org.opencontainers.image.title: AI Workbench org.opencontainers.image.vendor: SUSE LLC title: AI Workbench logo: https://raw.githubusercontent.com/rancher-sandbox/rancher-desktop-rdx-ai-workbench/refs/tags/v0.2.0/workbench.svg publisher: SUSE LLC short_description: AI Workbench extension - slug: splatform/epinio-docker-desktop version: 0.1.3 containerd_compatible: true labels: com.docker.desktop.extension.api.version: ">= 0.2.0" com.docker.desktop.extension.icon: https://epinio.io/images/icon-epinio.svg com.docker.extension.additional-urls: '[{"title":"Documentation","url":"https://docs.epinio.io/"},{"title":"Issues","url":"https://github.com/epinio/epinio/issues"},{"title":"CLI","url":"https://github.com/epinio/epinio/releases"},{"title":"Slack","url":"https://rancher-users.slack.com/?redir=%2Fmessages%2Fepinio"}]' com.docker.extension.detailed-description:

The Application Development Engine for Kubernetes

Tame your developer workflow to go from Code to URL in one step.

Epinio installs into any Kubernetes cluster to bring your application from source code to deployment and allow for Developers and Operators to work better together! com.docker.extension.publisher-url: https://epinio.io com.docker.extension.screenshots: '[{"alt": "Epinio after Installation", "url": "https://epinio.io/images/epinio-docker-desktop-screenshot.png"}]' org.opencontainers.image.description: Push from source to Kubernetes in one step org.opencontainers.image.title: Epinio org.opencontainers.image.vendor: Epinio by Krumware and SUSE title: Epinio logo: https://epinio.io/images/icon-epinio.svg publisher: Epinio by Krumware and SUSE short_description: Push from source to Kubernetes in one step - slug: julianb90/tachometer version: 0.1.1 containerd_compatible: true labels: com.docker.desktop.extension.api.version: 0.3.4 com.docker.desktop.extension.icon: https://raw.githubusercontent.com/julian-b90/tachometer/main/speedometer.png com.docker.extension.additional-urls: '[{"title":"Issues","url":"https://github.com/julian-b90/tachometer/issues"}]' com.docker.extension.categories: development,utility-tools com.docker.extension.changelog: "

V 0.1.1
### Changed

  • update dependencies
  • update node to latest LTS 20.17.0

" com.docker.extension.detailed-description: Extension shows real-time cpu and memory usage of containers com.docker.extension.publisher-url: https://github.com/julian-b90/tachometer com.docker.extension.screenshots: '[{"alt":"tachometer", "url":"https://raw.githubusercontent.com/julian-b90/tachometer/main/screenshot.png"}, {"alt":"details view", "url":"https://raw.githubusercontent.com/julian-b90/tachometer/main/screenshot_2.png"}]' org.opencontainers.image.description: Extension shows real-time cpu and memory usage of containers org.opencontainers.image.title: Tachometer org.opencontainers.image.vendor: julian-b90 title: Tachometer logo: https://raw.githubusercontent.com/julian-b90/tachometer/main/speedometer.png publisher: julian-b90 short_description: Extension shows real-time cpu and memory usage of containers - slug: docker/logs-explorer-extension version: 0.2.5 containerd_compatible: true labels: com.docker.desktop.extension.api.version: ">= 0.2.3" com.docker.desktop.extension.icon: https://docker-extension-screenshots.s3.amazonaws.com/logs-explorer-extension/icon.svg com.docker.extension.additional-urls: "[]" com.docker.extension.categories: utility-tools com.docker.extension.changelog: "
  • Fix missing logs on Windows.
" com.docker.extension.detailed-description: "

Logs Explorer provides deeper insight into your logs.

✨ Key features

  • Multiple filters: You can browse logs by status or log type, or you can select individual containers.
  • Advanced search functionality:
    • You can search for logs that have occurred after a certain amount of time or since a given date.
    • You can use regular expressions or exact matches when you search.
    • You can save each search query you enter into the search bar to help narrow your search with “sticky” search filters. You can save more than one query at a time.
  • Improved scrolling experience: When containers are running, scrolling is locked to the bottom by default so you see the latest logs.
" com.docker.extension.publisher-url: https://www.docker.com/ com.docker.extension.screenshots: '[ {"url": "https://docker-extension-screenshots.s3.amazonaws.com/logs-explorer-extension/0.2.1/1-all-containers.png", "alt": "View logs from all the containers"}, { "url": "https://docker-extension-screenshots.s3.amazonaws.com/logs-explorer-extension/0.2.1/2-filters.png", "alt": "View logs matching a filter" }, { "url": "https://docker-extension-screenshots.s3.amazonaws.com/logs-explorer-extension/0.2.1/2-collapsed-filters.png", "alt": "View logs with filter panel collapsed" }, { "url": "https://docker-extension-screenshots.s3.amazonaws.com/logs-explorer-extension/0.2.1/3-expanded-rows.png", "alt": "Expand rows" }, { "url": "https://docker-extension-screenshots.s3.amazonaws.com/logs-explorer-extension/0.2.1/3-log-type-status-filter.png", "alt": "View logs depending on the log type or the container status" }, { "url": "https://docker-extension-screenshots.s3.amazonaws.com/logs-explorer-extension/0.2.1/4-since-search-filter.png", "alt": "View logs since a duration" }, { "url": "https://docker-extension-screenshots.s3.amazonaws.com/logs-explorer-extension/0.2.1/5-from-search-filter.png", "alt": "View logs from a time" }, { "url": "https://docker-extension-screenshots.s3.amazonaws.com/logs-explorer-extension/0.2.1/6-tips.png", "alt": "Learn tips to search logs" }, { "url": "https://docker-extension-screenshots.s3.amazonaws.com/logs-explorer-extension/0.2.1/7-all-containers-dark-mode.png", "alt": "Dark mode" } ]' org.opencontainers.image.description: View all your container logs in one place so you can debug and troubleshoot faster. org.opencontainers.image.revision: 8351516879c55dae0d7324ce49214568307306da org.opencontainers.image.source: https://github.com/docker/logs-explorer-extension org.opencontainers.image.title: Logs Explorer org.opencontainers.image.vendor: Docker Inc. title: Logs Explorer logo: https://docker-extension-screenshots.s3.amazonaws.com/logs-explorer-extension/icon.svg publisher: Docker Inc. short_description: View all your container logs in one place so you can debug and troubleshoot faster. - slug: prakhar1989/dive-in version: 0.0.8 containerd_compatible: false labels: com.docker.desktop.extension.api.version: 0.3.0 com.docker.desktop.extension.icon: https://raw.githubusercontent.com/prakhar1989/dive-in/main/scuba.svg com.docker.extension.additional-urls: '[{"title":"Documentation","url":"https://github.com/prakhar1989/dive-in"}]' com.docker.extension.categories: utility-tools com.docker.extension.changelog: First version com.docker.extension.detailed-description:

Dive In

Explore docker images, layer contents, and discover ways to shrink the size of your Docker/OCI image.

com.docker.extension.publisher-url: https://prakhar.me com.docker.extension.screenshots: '[{"alt":"main page", "url":"https://github.com/prakhar1989/dive-in/blob/main/screenshots/1.png?raw=true"}, {"alt":"start containers", "url":"https://github.com/prakhar1989/dive-in/blob/main/screenshots/2.png?raw=true"}]' org.opencontainers.image.description: Explore docker images, layer contents, and discover ways to shrink the size of your Docker/OCI image. org.opencontainers.image.title: Dive In org.opencontainers.image.vendor: Prakhar Srivastav title: Dive In logo: https://raw.githubusercontent.com/prakhar1989/dive-in/main/scuba.svg publisher: Prakhar Srivastav short_description: Explore docker images, layer contents, and discover ways to shrink the size of your Docker/OCI image. - slug: joycelin79/newman-extension version: 0.0.7 containerd_compatible: true labels: com.docker.desktop.extension.api.version: ">= 0.0.1" com.docker.desktop.extension.icon: https://voyager.postman.com/icon/icon-newman-docker-orange-postman.svg com.docker.extension.additional-urls: '[{"title":"GitHub Repository","url":"https://github.com/loopDelicious/docker-extension"}, {"title":"Feedback and issues","url":"https://github.com/loopDelicious/docker-extension/issues"}, {"title":"Privacy Policy","url":"https://www.postman.com/legal/privacy-policy"},{"title":"Terms of Service","url":"https://www.postman.com/legal/terms"}]' com.docker.extension.changelog:
  • Added metadata to provide more information about the extension.
com.docker.extension.detailed-description:

Newman

The Postman extension uses Newman to run collections on Docker Desktop. View results of your API tests in a staging or production environment.

Known issues

In some cases, depending on test collections and test results, some buttons (expand/collapse folder, copy/paste) might be inoperative.

com.docker.extension.publisher-url: https://www.postman.com com.docker.extension.screenshots: '[{"alt":"View collection run results", "url":"https://user-images.githubusercontent.com/17693714/200926587-cc817844-419d-4a39-abfa-e3b9b43881ea.png"}, {"alt":"Add Postman API key", "url":"https://user-images.githubusercontent.com/17693714/200926910-a0fdba8d-02b0-4025-b7d6-95eb556eefa7.png"},{"alt":"Select Postman collection to run", "url":"https://user-images.githubusercontent.com/17693714/200926775-35e8dc5a-6c44-45de-80a8-f80430c81066.png"}]' org.opencontainers.image.description: Run your Postman collections from Docker Desktop. org.opencontainers.image.title: Newman org.opencontainers.image.vendor: Postman title: Newman logo: https://voyager.postman.com/icon/icon-newman-docker-orange-postman.svg publisher: Postman short_description: Run your Postman collections from Docker Desktop. - slug: docker/resource-usage-extension version: 1.0.3 containerd_compatible: true labels: com.docker.desktop.extension.api.version: 0.3.0 com.docker.desktop.extension.icon: https://docker-extension-screenshots.s3.amazonaws.com/resource-usage-extension/icon.svg com.docker.extension.additional-urls: "" com.docker.extension.categories: utility-tools com.docker.extension.changelog: Fix style on grid buttons com.docker.extension.detailed-description: "

With Resource Usage you can:

  • Find out which containers or Docker Compose projects consume the most resources.
  • Monitor the evolution of resource usage by containers over time.
  • See how much CPU, Memory, Network and Disk space your containers are using.
" com.docker.extension.publisher-url: https://www.docker.com/ com.docker.extension.screenshots: '[ {"url": "https://docker-extension-screenshots.s3.amazonaws.com/resource-usage-extension/1-processes.png", "alt": "View all docker containers resources in a table"}, {"url": "https://docker-extension-screenshots.s3.amazonaws.com/resource-usage-extension/2-graphs.png", "alt": "View containers resource usage as graphs"} ]' org.opencontainers.image.description: Monitor and manage live data stream for running containers. org.opencontainers.image.revision: 1.0.3 org.opencontainers.image.source: https://github.com/docker/resource-usage-extension org.opencontainers.image.title: Resource usage org.opencontainers.image.vendor: Docker Inc. title: Resource usage logo: https://docker-extension-screenshots.s3.amazonaws.com/resource-usage-extension/icon.svg publisher: Docker Inc. short_description: Monitor and manage live data stream for running containers. - slug: anchore/docker-desktop-extension version: 0.5.1 containerd_compatible: false labels: com.docker.desktop.extension.api.version: ">= 0.2.3" com.docker.desktop.extension.icon: https://user-images.githubusercontent.com/590471/164125752-b83d973c-f161-4d54-889f-352dee0ec795.svg com.docker.extension.additional-urls: '[{"title":"Support","url":"https://github.com/anchore/docker-desktop-extension-support"}]' com.docker.extension.screenshots: '[{"alt": "image listing", "url": "https://user-images.githubusercontent.com/590471/164122365-efb150a9-3c97-42d2-bb46-7ba434fc21d2.png"},{"alt": "package listing", "url": "https://user-images.githubusercontent.com/590471/164122366-a7b89526-29c0-498c-b23b-d96667368637.png"},{"alt": "vulnerability listing", "url": "https://user-images.githubusercontent.com/590471/164122368-601d1ee2-a77d-4c0f-a98a-1aa68ea79d2a.png"}]' org.opencontainers.image.description: Content and security analysis for container images org.opencontainers.image.title: anchore org.opencontainers.image.vendor: Anchore Inc. title: anchore logo: https://user-images.githubusercontent.com/590471/164125752-b83d973c-f161-4d54-889f-352dee0ec795.svg publisher: Anchore Inc. short_description: Content and security analysis for container images - slug: ignatandrei/blockly-automation version: 0.0.7 containerd_compatible: true labels: com.docker.desktop.extension.api.version: ">= 0.2.0" com.docker.desktop.extension.icon: https://github.com/ignatandrei/BlocklyAutomation/wiki/imgs/logoBADocker.png com.docker.extension.additional-urls: '[{"title":"Main Page","url":"https://github.com/ignatandrei/BlocklyAutomation/wiki/DockerExtension"}]' com.docker.extension.categories: development,testing-tools com.docker.extension.changelog:
  • first version
com.docker.extension.detailed-description:

Description

You can automate pretty much every docker command. Press Execute to see in action and LoadBlocks for more examples.

com.docker.extension.publisher-url: https://github.com/ignatandrei/blocklyautomation com.docker.extension.screenshots: '[{"alt":"Load Demos", "url":"https://raw.githubusercontent.com/wiki/ignatandrei/BlocklyAutomation/imgs/DockerExtension/LoadBlocks.png"}, {"alt":"start containers", "url":"https://raw.githubusercontent.com/wiki/ignatandrei/BlocklyAutomation/imgs/DockerExtension/StartContainers.png"}]' org.opencontainers.image.description: A extension that displays lowCode with Blockly for any Docker command org.opencontainers.image.title: Blockly Automation org.opencontainers.image.vendor: Andrei Ignat title: Blockly Automation logo: https://github.com/ignatandrei/BlocklyAutomation/wiki/imgs/logoBADocker.png publisher: Andrei Ignat short_description: A extension that displays lowCode with Blockly for any Docker command - slug: docker/disk-usage-extension version: 0.2.8 containerd_compatible: false labels: com.docker.desktop.extension.api.version: ">= 0.2.3" com.docker.desktop.extension.icon: https://docker-extension-screenshots.s3.us-east-1.amazonaws.com/disk-usage-extension/hard-drive.svg com.docker.extension.additional-urls: "[]" com.docker.extension.changelog:
  • Adding 'Select all' button for reclaimable space options.
com.docker.extension.detailed-description: "

Disk Usage displays and categorizes the disk space used by Docker. It also shows you how much of the disk space is reclaimable and provides an easy one-click experience to reclaim space.

Who might find it useful?

  • If you need more visibility about how much space Docker resources (e.g. images, volumes, etc) are using and how much of it is reclaimable.

  • If you are looking for a quick way to clean up disk space used by Docker.

How it works

You can reclaim the disk space used by Docker by removing:

  • Stopped containers
  • Unused images
  • Dangling images
  • Build cache
  • Unused volumes
" com.docker.extension.publisher-url: https://www.docker.com/ com.docker.extension.screenshots: '[ {"url": "https://docker-extension-screenshots.s3.amazonaws.com/disk-usage-extension/1-disk-usage.png", "alt": "Disk usage stats"}, {"url": "https://docker-extension-screenshots.s3.amazonaws.com/disk-usage-extension/2-reclaim-popup.png", "alt": "Reclaim space popup"}, {"url": "https://docker-extension-screenshots.s3.amazonaws.com/disk-usage-extension/3-space-reclaimed.png", "alt": "Space reclaimed successfully"} ]' org.opencontainers.image.description: Optimize your disk space by removing unused objects from Docker Desktop. org.opencontainers.image.revision: 929ff4d90be404fdf4325537286603d6c7c515ae org.opencontainers.image.source: https://github.com/docker/disk-usage-extension org.opencontainers.image.title: Disk Usage org.opencontainers.image.vendor: Docker Inc. title: Disk Usage logo: https://docker-extension-screenshots.s3.us-east-1.amazonaws.com/disk-usage-extension/hard-drive.svg publisher: Docker Inc. short_description: Optimize your disk space by removing unused objects from Docker Desktop. - slug: harpooncorp/harpoon-ext version: 0.0.6 containerd_compatible: true labels: com.docker.desktop.extension.api.version: ">=0.2.3" com.docker.desktop.extension.icon: https://docker-desktop-screenshots.s3.us-west-2.amazonaws.com/harpoon_logo_white_whale_transparent.png com.docker.extension.additional-urls: '[{"title":"Documentation","url":"https://docs.harpoon.io/en/latest/introduction.html"}, {"title":"Features","url":"https://docs.harpoon.io/en/latest/features.html"}, {"title":"Support","url":"https://www.harpoon.io/contact"}, {"title":"Book Demo","url":"https://www.harpoon.io/demo"}]' com.docker.extension.categories: kubernetes com.docker.extension.changelog: "" com.docker.extension.detailed-description: harpoon is a drag and drop Kubernetes tool for deploying any software in seconds. Our visual Kubernetes interface enables anyone to deploy production-grade software with no code. Whether you're new to Kubernetes and are looking for the best way to learn or a seasoned pro, harpoon has all the features you need to be successful in deploying and configuring your software using the industry-leading container orchestrator, all with no code. com.docker.extension.publisher-url: https://harpoon.io com.docker.extension.screenshots: '[{"alt":"harpoon project page dark mode", "url":"https://docker-desktop-screenshots.s3.us-west-2.amazonaws.com/project-dark.png"}, {"alt":"harpoon project page light mode", "url":"https://docker-desktop-screenshots.s3.us-west-2.amazonaws.com/project-light.png"}, {"alt":"harpoon home page dark mode", "url":"https://docker-desktop-screenshots.s3.us-west-2.amazonaws.com/home-dark.png"}, {"alt":"harpoon home page light mode", "url":"https://docker-desktop-screenshots.s3.us-west-2.amazonaws.com/home-light.png"}]' org.opencontainers.image.description: Docker Extension for the No Code Kubernetes platform org.opencontainers.image.title: harpoon org.opencontainers.image.vendor: harpoon Corp title: harpoon logo: https://docker-desktop-screenshots.s3.us-west-2.amazonaws.com/harpoon_logo_white_whale_transparent.png publisher: harpoon Corp short_description: Docker Extension for the No Code Kubernetes platform - slug: vklokun/docker-desktop-extension version: 0.1.1 containerd_compatible: true labels: com.docker.desktop.extension.api.version: ">= 0.2.3" com.docker.desktop.extension.icon: https://raw.githubusercontent.com/cncf/artwork/ec3936fa0256c768b538247d20f130d293a9faed/projects/kubescape/stacked/color/kubescape-stacked-color.svg com.docker.extension.account-info: required com.docker.extension.additional-urls: "" com.docker.extension.categories: kubernetes,security com.docker.extension.changelog:

Extension changelog

  • Support access key required by ARMO platform
  • Update Kubescape helm chart name

com.docker.extension.detailed-description:

Kubescape Extension for Docker Desktop

Kubescape helps harden your Kubernetes cluster by providing insight into your cluster's security posture. Some of the features that help you achieve this are - regular configuration and image scans, visualizing your RBAC rules and suggesting automatic fixes where applicable.

The Kubescape Extension for Docker Desktop works by installing the Kubescape in-cluster components, connecting them to ARMO Platform and providing insights into the Kubernetes cluster deployed by Docker Desktop via the dashboard on ARMO Platform. com.docker.extension.publisher-url: https://cloud.armosec.io/ com.docker.extension.screenshots: '[ { "alt": "Kubescape Extension for Docker Desktop, Select Provider screen", "url": "https://raw.githubusercontent.com/kubescape/docker-desktop-extension/main/docs/screenshots/dark-01.png" }, { "alt": "Kubescape Extension for Docker Desktop, Sign Up screen", "url": "https://raw.githubusercontent.com/kubescape/docker-desktop-extension/main/docs/screenshots/dark-02.png" }, { "alt": "Kubescape Extension for Docker Desktop, Secure Your Cluster screen", "url": "https://raw.githubusercontent.com/kubescape/docker-desktop-extension/main/docs/screenshots/dark-03.png" }, { "alt": "Kubescape Extension for Docker Desktop, Cluster Secured screen", "url": "https://raw.githubusercontent.com/kubescape/docker-desktop-extension/main/docs/screenshots/dark-04.png" }, { "alt": "Kubescape Extension for Docker Desktop, Monitor screen", "url": "https://raw.githubusercontent.com/kubescape/docker-desktop-extension/main/docs/screenshots/dark-05.png" } ]' org.opencontainers.image.description: Secure your Kubernetes cluster and gain insight into your cluster’s security posture via an easy-to-use online dashboard. org.opencontainers.image.licenses: Apache-2.0 org.opencontainers.image.title: Kubescape org.opencontainers.image.vendor: ARMO title: Kubescape logo: https://raw.githubusercontent.com/cncf/artwork/ec3936fa0256c768b538247d20f130d293a9faed/projects/kubescape/stacked/color/kubescape-stacked-color.svg publisher: ARMO short_description: Secure your Kubernetes cluster and gain insight into your cluster’s security posture via an easy-to-use online dashboard. - slug: caretdev/intersystems-extension version: 0.1.7 containerd_compatible: true labels: com.docker.desktop.extension.api.version: ">= 0.2.3" com.docker.desktop.extension.icon: https://raw.githubusercontent.com/caretdev/docker-intersystems-extension/main/intersystems.svg com.docker.extension.additional-urls: '[{"title":"InterSystems","url":"https://intersystems.com/"},{"title":"Support","url":"https://github.com/caretdev/docker-intersystems-extension/issues"},{"title":"Discord","url":"https://discord.gg/Bt5DUwJhdt"}]' com.docker.extension.categories: image-registry com.docker.extension.changelog: "" com.docker.extension.detailed-description: '

InterSystems Container Registry

This provides a new distribution channel for customers to access container-based releases and previews. All Community Edition images are available in a public repository with no login required. All full released images (IRIS, IRIS for Health, Health Connect, System Alerting and Monitoring, InterSystems Cloud Manager) and utility images (such as arbiter, Web Gateway, and PasswordHash) require a login token, generated from your WRC (Worldwide Response Center) account credentials.

Why do I need this Extension

This extension provides integrated UI for InterSystems Container Registry, so, you can easily follow any updates, and quickly find and pull any images available on Container Registry there.

Features available

  • Observe the list of available public images
  • Observe the list of available private images for users with access to WRC
  • Easy pulling images
  • Delete local images
  • Copy image name with tag
  • OS Support
    • Windows x86-64
    • Linux x86-64 and ARM64
    • macOS x86-64 and ARM64
  • Filter images by name and tag
  • Filter for ARM64 images

How to use

It is already usable right after installation. And all public images are already available. The list of images is cached, and it is possible to refresh the list manually.

To get access to private images with your WRC account, you have to go to https://containers.intersystems.com login there, and using provided token login in docker.

     docker
      login -u="wrc_username" -p="your_token"
      containers.intersystems.com   
After successful login, return to the extension and press the Refresh button in the top right corner.

Additionally

InterSystems Developer Community

InterSystems Developer Community is a global network of highly experienced technology experts, influencers, and thought leaders who have expertise in InterSystems technologies. It’s a multilingual platform both for InterSystems employees, customers and partners.

' com.docker.extension.publisher-url: https://github.com/caretdev/docker-intersystems-extension com.docker.extension.screenshots: '[{"url":"https://raw.githubusercontent.com/caretdev/docker-intersystems-extension/main/img/screenshot1.png","alt":"Community images"},{"url":"https://raw.githubusercontent.com/caretdev/docker-intersystems-extension/main/img/screenshot2.png","alt":"Community ARM64 images"}]' org.opencontainers.image.description: Convenient way to access InterSystems Container Registry, public and private images of such products as IRIS and IRIS for Health and many others in one place. org.opencontainers.image.title: InterSystems org.opencontainers.image.vendor: CaretDev Corp. title: InterSystems logo: https://raw.githubusercontent.com/caretdev/docker-intersystems-extension/main/intersystems.svg publisher: CaretDev Corp. short_description: Convenient way to access InterSystems Container Registry, public and private images of such products as IRIS and IRIS for Health and many others in one place. ================================================ FILE: pkg/rancher-desktop/assets/lima-config.yaml ================================================ # Default Lima configuration; parts will be overridden in code. # Rancher Desktop ships with a patched QEMU that supports the Apple M4 CPU # So override Lima 1.0.3 falling back to cortex-a72. cpuType: aarch64: host ssh: loadDotSSHPubKeys: false firmware: legacyBIOS: false containerd: system: false user: false # Provisioning scripts run on every boot, not just initial VM provisioning. provision: - # When the ISO image is updated, only preserve selected data from /etc but otherwise use the new files. # Update files in /usr/local on the data volume from the new versions on the ISO. mode: system script: | #!/bin/sh set -o errexit -o nounset -o xtrace mkdir -p /bootfs mount --bind / /bootfs # /bootfs/etc is empty on first boot because it has been moved to /mnt/data/etc by lima if [ -f /bootfs/etc/os-release ]; then # Alpine turned /etc/os-release into a symlink; we dereference it again. If we still have a symlink # here, then this is an upgrade from an older version and needs to go through the migration. if [ -L /etc/os-release ] || ! diff -q /etc/os-release /bootfs/etc/os-release; then # When we are upgrading from an ISO we will install new packages during boot. # But Lima will restore the old /etc/apk/world file and run `apk fix --no-network` # to restore packages from cache that were installed manually. This will however # uninstall all packages again that were not previously installed because they are # not listed in the old world file. We need to install them once more from the # boot media to update the world file. # We are not bothering with uninstalling packages that are not part of the new release. apk add --no-network --keys-dir /bootfs/etc/apk/keys --repositories-file /bootfs/etc/apk/repositories \ $(cat /bootfs/etc/apk/world) # Using a temp file just in case dereferencing a symlink onto itself has a race condition. cp -L /etc/os-release /etc/os-release.tmp mv /etc/os-release.tmp /etc/os-release cp /etc/machine-id /bootfs/etc cp /etc/ssh/ssh_host* /bootfs/etc/ssh/ mkdir -p /etc/docker /etc/rancher cp -pr /etc/docker /bootfs/etc cp -pr /etc/rancher /bootfs/etc rm -rf /mnt/data/etc.prev mkdir /mnt/data/etc.prev mv /etc/* /mnt/data/etc.prev cp -L /bootfs/etc/os-release /tmp/os-release mv /bootfs/etc/* /etc mv /tmp/os-release /etc # install updated files from /usr/local, e.g. nerdctl, buildkit, cni plugins cp -pr /bootfs/usr/local /usr # Keep the lima-init.log around for debugging cp /var/log/lima-init.log /mnt/data/lima-init-upgrade.log # lima has applied changes while the "old" /etc was in place; restart to apply them to the updated one. reboot fi fi umount /bootfs rmdir /bootfs - # make sure we booted with the right cgroup mode; k3s versions before 1.20.4 only support cgroup v1 mode: system script: | #!/bin/sh set -o errexit -o nounset -o xtrace RC_CGROUP_MODE=unified if ! grep -q -E "^#?rc_cgroup_mode=\"$RC_CGROUP_MODE\"" /etc/rc.conf; then sed -i -E "s/^#?rc_cgroup_mode=\".*\"/rc_cgroup_mode=\"$RC_CGROUP_MODE\"/" /etc/rc.conf # avoid reboot loop if sed failed for any reason if grep -q -E "^rc_cgroup_mode=\"$RC_CGROUP_MODE\"" /etc/rc.conf; then reboot fi fi - # return unused space from the data volume back to the host mode: system script: | #!/bin/sh set -o errexit -o nounset -o xtrace fstrim /mnt/data - # allow more than 10 sessions over the master control path mode: system script: | #!/bin/sh set -o errexit -o nounset -o xtrace sed -i -E 's/^#?MaxSessions +[0-9]+/MaxSessions 25/g' /etc/ssh/sshd_config rc-service --ifstarted sshd reload - # Persist /root directory on data volume mode: system script: | #!/bin/sh set -o errexit -o nounset -o xtrace if ! [ -d /mnt/data/root ]; then mkdir -p /root mv /root /mnt/data/root fi mkdir -p /root mount --bind /mnt/data/root /root - # Create /etc/docker/certs.d symlink mode: system script: | #!/bin/sh set -o errexit -o nounset -o xtrace mkdir -p /etc/docker # Delete certs.d if it is a symlink (from previous boot). [ -L /etc/docker/certs.d ] && rm /etc/docker/certs.d # Create symlink if certs.d doesn't exist (user may have created a regular directory). if [ ! -e /etc/docker/certs.d ]; then # We don't know if the host is Linux or macOS, so we take a guess based on which mountpoint exists. if [ -d "/Users/{{.User}}" ]; then ln -s "/Users/{{.User}}/.docker/certs.d" /etc/docker elif [ -d "/home/{{.User}}" ]; then ln -s "/home/{{.User}}/.docker/certs.d" /etc/docker fi fi - # Make sure hostname doesn't change during upgrade from earlier versions mode: system script: | #!/bin/sh hostname lima-rancher-desktop - # Clean up filesystems mode: system script: | #!/bin/sh set -o errexit -o nounset -o xtrace # During boot is the only safe time to delete old k3s versions. rm -rf /var/lib/rancher/k3s/data # Delete all tmp files older than 3 days. find /tmp -depth -mtime +3 -delete - # Make mount-points shared. mode: system script: | #!/bin/sh set -o errexit -o nounset -o xtrace for dir in / /etc /tmp /var/lib; do mount --make-shared "${dir}" done - # This sets up cron (used for logrotate) mode: system script: | #!/bin/sh # Move logrotate to hourly, because busybox crond only handles time jumps up # to one hour; this ensures that if the machine is suspended over long # periods, things will still happen often enough. This is idempotent. mv -n /etc/periodic/daily/logrotate /etc/periodic/hourly/ rc-update add crond default rc-service crond start - # Ensure the user is in the docker group to access the docker socket mode: system script: | set -o errexit -o nounset -o xtrace usermod --append --groups docker "{{.User}}" - # Install mkcert and prepare default/fallback cert for localhost mode: system script: | export CAROOT=/run/mkcert mkdir -p $CAROOT cd $CAROOT # Remove old mkcert certificates rm -f /usr/local/share/ca-certificates/mkcert_development_CA_*.crt mkcert -install mkcert localhost chown -R nobody:nobody $CAROOT - # Configure HTTPS_PROXY to OpenResty mode: system script: | set -o errexit -o nounset -o xtrace # openresty is backgrounding itself (and writes its own pid file) sed -i 's/^command_background/#command_background/' /etc/init.d/rd-openresty # configure proxy only when allowed-images exists allowed_images_conf=/usr/local/openresty/nginx/conf/allowed-images.conf # Remove the reference to an obsolete image conf filename obsolete_image_allow_list_conf=/usr/local/openresty/nginx/conf/image-allow-list.conf setproxy="[ -f $allowed_images_conf ] && supervise_daemon_args=\"-e HTTPS_PROXY=http://127.0.0.1:3128 \${supervise_daemon_args:-}\" || true" for svc in containerd docker; do sed -i "\#-f $allowed_images_conf#d" /etc/init.d/$svc sed -i "\#-f $obsolete_image_allow_list_conf#d" /etc/init.d/$svc echo "$setproxy" >> /etc/init.d/$svc done # Make sure openresty log directory exists install -d -m755 /var/log/openresty - # mount bpffs to allow containers to leverage bpf, and make both bpffs and # cgroupfs shared mounts so the pods can mount them correctly mode: system script: | #!/bin/sh set -o errexit mount bpffs -t bpf /sys/fs/bpf mount --make-shared /sys/fs/bpf mount --make-shared /sys/fs/cgroup - # we run trivy as root now; remove any cached databases installed into the user directory by previous version # trivy.db is 600M and trivy-java.db is 1.1G mode: user script: | rm -rf "${HOME}/.cache/trivy" portForwards: - guestPortRange: [1, 65535] guestIPMustBeZero: true hostIP: "0.0.0.0" proto: any ================================================ FILE: pkg/rancher-desktop/assets/networks-config.yaml ================================================ # Path to socket_vmnet executable. Because socket_vmnet is invoked via sudo, it # must be installed where only root can modify/replace it. This means also none # of the parent directories should be writable by the user. # # The varRun directory also must not be writable by the user because it will # include the socket_vmnet pid file. socket_vmnet will be terminated via # sudo, so replacing the pid file would allow killing of arbitrary privileged # processes. varRun however MUST be writable by the daemon user. # # None of the paths segments may be symlinks, which is why it has to be /private/var # instead of /var etc. paths: socketVMNet: /opt/rancher-desktop/bin/socket_vmnet varRun: /private/var/run sudoers: /private/etc/sudoers.d/zzzzz-rancher-desktop-lima group: everyone networks: rancher-desktop-shared: mode: shared gateway: 192.168.205.1 dhcpEnd: 192.168.205.254 netmask: 255.255.255.0 host: mode: host gateway: 192.168.206.1 dhcpEnd: 192.168.206.254 netmask: 255.255.255.0 # We will add bridged-en0 etc. networks, one for each host interface. ================================================ FILE: pkg/rancher-desktop/assets/scripts/10-flannel.conflist ================================================ { "name":"cbr0", "cniVersion":"0.3.1", "plugins":[ { "type":"flannel", "delegate":{ "hairpinMode":true, "forceAddress":true, "isDefaultGateway":true } }, { "type":"portmap", "capabilities":{ "portMappings":true } } ] } ================================================ FILE: pkg/rancher-desktop/assets/scripts/buildkit.confd ================================================ # config file for /etc/init.d/buildkit # overrides the main command executed by the supervise daemon buildkitd_command="/usr/local/bin/buildkitd" # any other options you want to pass to buildkitd_command buildkitd_opts="--addr=unix:///run/buildkit/buildkitd.sock --containerd-worker=true --containerd-worker-addr=/run/k3s/containerd/containerd.sock --containerd-worker-gc --oci-worker=false" # Settings for process limits (ulimit) #ulimit_opts="-c unlimited -n 1048576 -u unlimited" # seconds to wait for sending SIGTERM and SIGKILL signals when stopping buildkitd #signal_retry="TERM/60/KILL/10" # where buildkit stdout (and perhaps stderr) goes. #log_file="/var/log/buildkit.log" # where buildkit stderr optionally goes. # if this is not set, the value in 'logfile' is used #err_file="/var/log/buildkit-err.log" # mode of the log files #log_mode=0644 # user that owns the log files (no group root on WSL) log_owner=root # to override the default supervise_daemon_args #supervise_daemon_opts="" ================================================ FILE: pkg/rancher-desktop/assets/scripts/buildkit.initd ================================================ #!/sbin/openrc-run supervisor=supervise-daemon name="BuildKit Daemon" description="Standalone buildkitd" command="${buildkitd_command:-/usr/bin/buildkitd}" command_args="${buildkitd_opts:---oci-worker=false --containerd-worker=true}" rc_ulimit="${ulimit_opts:--c unlimited -n 1048576 -u unlimited}" retry="${signal_retry:-TERM/60/KILL/10}" log_file="${log_file:-/var/log/${RC_SVCNAME}.log}" err_file="${err_file:-${log_file}}" log_mode="${log_mode:-0644}" log_owner="${log_owner:-root}" supervise_daemon_args="${supervise_daemon_opts:---stderr \"${err_file}\" --stdout \"${log_file}\"}" start_pre() { checkpath -f -m "$log_mode" -o "$log_owner" "$log_file" "$err_file" } ================================================ FILE: pkg/rancher-desktop/assets/scripts/cert-manager.yaml ================================================ --- apiVersion: v1 kind: Namespace metadata: name: cert-manager --- apiVersion: helm.cattle.io/v1 kind: HelmChart metadata: name: cert-manager namespace: kube-system spec: chart: "https://%{KUBERNETES_API}%/static/rancher-desktop/cert-manager.tgz" targetNamespace: cert-manager # Old versions of the helm-controller don't support createNamespace, so we # created the namespace ourselves. createNamespace: false ================================================ FILE: pkg/rancher-desktop/assets/scripts/configure-allowed-images ================================================ #!/bin/sh # This script configures the VM for the allowed-images feature. # shellcheck shell=ash set -o errexit -o nounset # Create nobody user and group for nginx. addgroup -S -g 65534 nobody 2>/dev/null || true adduser -S -D -H -h /dev/null -s /sbin/nologin -u 65534 -G nobody -g nobody nobody 2>/dev/null || true # Install mkcert and create default certs for localhost. export CAROOT=/run/mkcert mkdir -p $CAROOT cd $CAROOT # Remove old mkcert certificates rm -f /usr/local/share/ca-certificates/mkcert_development_CA_*.crt mkcert -install mkcert localhost chown -R nobody:nobody $CAROOT # configure proxy only when allowed-images exists allowed_images_conf=/usr/local/openresty/nginx/conf/allowed-images.conf setproxy="[ -f $allowed_images_conf ] && supervise_daemon_args=\"-e HTTPS_PROXY=http://127.0.0.1:3128 \${supervise_daemon_args:-}\" || true" sed -i "\#-f $allowed_images_conf#d" /etc/init.d/containerd echo "$setproxy" >> /etc/init.d/containerd # openresty is backgrounding itself (and writes its own pid file) sed -i 's/^command_background/#command_background/' /etc/init.d/rd-openresty # Make sure openresty log directory exists install -d -m755 /var/log/openresty ================================================ FILE: pkg/rancher-desktop/assets/scripts/docker-credential-rancher-desktop ================================================ #!/bin/sh set -eu source /etc/rancher/desktop/credfwd DATA="@-" # The "list" command doesn't have a payload on STDIN [ "$1" = "list" ] && DATA="" # $CREDFWD_CURL_OPTS is intentionally *not* quoted exec curl --silent --user "$CREDFWD_AUTH" --data "$DATA" --noproxy '*' --fail-with-body ${CREDFWD_CURL_OPTS:-} "$CREDFWD_URL/$1" ================================================ FILE: pkg/rancher-desktop/assets/scripts/install-containerd-shims ================================================ #!/bin/sh set -o errexit -o nounset -o pipefail dest=/usr/local/containerd-shims # Copy all shims into the data volume so they become part of snapshots. # TODO Maybe use rsync to avoid copying files repeatedly? mkdir -p "$dest" for dir in "$@"; do if [[ "$(uname -a)" =~ microsoft ]]; then dir=$(wslpath -a -u "$dir") fi cp "${dir}/containerd-shim-"* "$dest" || : done # Make sure all shims are executable. for file in "${dest}/"*; do if [ -e "$file" ]; then chmod 755 "$file" fi done # Create symlinks to each shim into /usr/local/bin. # In the future this will enable us putting only shims from an allow list on the PATH. find /usr/local/bin -type l -name 'containerd-shim-*' -delete find "$dest" -type f -exec ln -sf {} /usr/local/bin \; ================================================ FILE: pkg/rancher-desktop/assets/scripts/install-k3s ================================================ #!/bin/sh set -o errexit -o nounset -o pipefail if [ -n "${XTRACE:-}" ]; then set -o xtrace fi VERSION="${1}" CACHE_DIR="${CACHE_DIR:-${2}}" # Update symlinks for k3s and images to new version K3S_DIR="${CACHE_DIR}/${VERSION}" if [ ! -d "${K3S_DIR}" ]; then echo "Directory ${K3S_DIR} does not exist" exit 1 fi # Make sure any outdated kubeconfig file is gone mkdir -p /etc/rancher/k3s rm -f /etc/rancher/k3s/k3s.yaml K3S=k3s ARCH=amd64 if [ "$(uname -m)" = "aarch64" ]; then K3S=k3s-arm64 ARCH=arm64 fi # Add images IMAGES="/var/lib/rancher/k3s/agent/images" mkdir -p "${IMAGES}" IMAGEPATH="${K3S_DIR}/k3s-airgap-images-${ARCH}" if [ -f "${IMAGEPATH}.tar.zst" ]; then ln -s -f "${IMAGEPATH}.tar.zst" "${IMAGES}" fi if [ -f "${IMAGEPATH}.tar" ]; then ln -s -f "${IMAGEPATH}.tar" "${IMAGES}" fi # Add k3s binary ln -s -f "${K3S_DIR}/${K3S}" /usr/local/bin/k3s # The file system may be readonly (on macOS) chmod a+x "${K3S_DIR}/${K3S}" || true # Make sure any old manifests are removed before configuring k3s again. # All Rancher Desktop manifest have a name like z123-foo-bar, so only delete # those. That way provisioning scripts can still create other manifests. # We need to create the directory before we run `k3s server ...` because # we install additional manifests that k3s will install during startup. MANIFESTS=/var/lib/rancher/k3s/server/manifests rm -rf "${MANIFESTS}/z"[0-9]* mkdir -p "$MANIFESTS" STATIC=/var/lib/rancher/k3s/server/static/rancher-desktop rm -rf "$STATIC" mkdir -p "$STATIC" ================================================ FILE: pkg/rancher-desktop/assets/scripts/install-wsl-helpers ================================================ #!/bin/sh # This script installs WSL helpers into the shared WSL mount at `/mnt/wsl`. # Usage: $0 # shellcheck shell=ash set -o errexit -o nounset # The nerdctl shim must be setuid root to be able to create bind mounts within # /mnt/wsl so that nerdctl can see it. mkdir -p "/mnt/wsl/rancher-desktop/bin/" cp "${1}" "/mnt/wsl/rancher-desktop/bin/nerdctl" chmod u+s "/mnt/wsl/rancher-desktop/bin/nerdctl" ================================================ FILE: pkg/rancher-desktop/assets/scripts/k3s-containerd-config.toml ================================================ version = 2 root = "/var/lib/rancher/k3s/agent/containerd" state = "/run/k3s/containerd" [grpc] address = "/run/k3s/containerd/containerd.sock" [plugins."io.containerd.internal.v1.opt"] path = "/var/lib/rancher/k3s/agent/containerd" [plugins."io.containerd.grpc.v1.cri"] stream_server_address = "127.0.0.1" stream_server_port = "10010" enable_selinux = false enable_unprivileged_ports = true enable_unprivileged_icmp = true sandbox_image = "rancher/mirrored-pause:3.6" [plugins."io.containerd.grpc.v1.cri".containerd] snapshotter = "overlayfs" disable_snapshot_annotations = true [plugins."io.containerd.grpc.v1.cri".cni] bin_dir = "/usr/libexec/cni" conf_dir = "/etc/cni/net.d" [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc] runtime_type = "io.containerd.runc.v2" [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] SystemdCgroup = false [plugins."io.containerd.grpc.v1.cri".registry] config_path = "/var/lib/rancher/k3s/agent/etc/containerd/certs.d" ================================================ FILE: pkg/rancher-desktop/assets/scripts/logrotate-k3s ================================================ /var/log/k3s.log { missingok notifempty copytruncate } ================================================ FILE: pkg/rancher-desktop/assets/scripts/logrotate-lima-guestagent ================================================ /var/log/lima-guestagent.log { missingok notifempty copytruncate } ================================================ FILE: pkg/rancher-desktop/assets/scripts/logrotate-openresty ================================================ /var/log/openresty/*.log { missingok sharedscripts postrotate /etc/init.d/rd-openresty --quiet --ifstarted reopen endscript } ================================================ FILE: pkg/rancher-desktop/assets/scripts/moproxy.initd ================================================ #!/sbin/openrc-run name=moproxy description="A transparent TCP to SOCKSv5/HTTP proxy." extra_started_commands="enable disable reload" description_enable="Start redirecting the network traffic to the HTTP proxy." description_disable="Stop redirecting the network traffic to the HTTP proxy." description_reload="Reload the proxy list." # TCP Listen address : ${host:=${MOPROXY_HOST:-"::"}} # TCP Listen port : ${port:=${MOPROXY_PORT:-"2080"}} # List of backend proxy servers : ${proxy_list:=${MOPROXY_PROXYLIST:-"/etc/moproxy/proxy.ini"}} # Additional arguments to pass to moproxy : ${moproxy_args:=${MOPROXY_ARGS:-""}} # Override this argument to disable the use of TLS SNI : ${moproxy_remotedns:=${MOPROXY_REMOTE_DNS:-"--remote-dns"}} # Comma-separated list of port traffic to redirect to moproxy : ${ports_redirected:=${MOPROXY_REDIRECTED_PORT:-"80,443"}} # Comma-separated list of hostname to not redirect to the proxy : ${noproxy_rules:=${MOPROXY_NOPROXY:-"0.0.0.0/8,10.0.0.0/8,127.0.0.0/8,169.254.0.0/16,172.16.0.0/12,192.168.0.0/16,224.0.0.0/4,240.0.0.0/4"}} command="'${MOPROXY_BINARY:-/usr/sbin/moproxy}'" command_args="--host ${host} --port ${port} ${moproxy_remotedns} --list ${proxy_list} ${moproxy_args}" command_background="yes" pidfile="/run/${name}.pid" MOPROXY_LOGFILE="${MOPROXY_LOGFILE:-${LOG_DIR:-/var/log}/${RC_SVCNAME}.log}" output_log="'${MOPROXY_LOGFILE}'" error_log="'${MOPROXY_LOGFILE}'" iptables_redirect_to_moproxy_chain() { iptables --table nat --$1 $2 --protocol tcp --match multiport --dports "${ports_redirected}" --jump MOPROXY } iptables_redirect() { iptables --table nat --append MOPROXY --protocol tcp --jump REDIRECT --to-port "${port}" } iptables_accept() { iptables --table nat --append MOPROXY --protocol tcp --destination "$1" --jump ACCEPT } add_noproxy_rules() { for i in ${noproxy_rules//,/ } do iptables_accept "$i" done } enable_redirection_to_moproxy_chain() { if ! iptables_redirect_to_moproxy_chain check $1 &> /dev/null then iptables_redirect_to_moproxy_chain append $1 else einfo "Rule already in table" fi } disable_redirection_to_moproxy_chain() { while iptables_redirect_to_moproxy_chain check $1 &> /dev/null do iptables_redirect_to_moproxy_chain delete $1 done } create_moproxy_chain() { iptables --table nat --new MOPROXY } delete_moproxy_chain() { iptables --table nat --flush MOPROXY iptables --table nat --delete-chain MOPROXY } depend() { after iptables ip6tables } enable() { einfo "Starting the iptables rules to start redirection of ports ${ports_redirected} to ${name}" create_moproxy_chain add_noproxy_rules iptables_redirect enable_redirection_to_moproxy_chain OUTPUT enable_redirection_to_moproxy_chain PREROUTING } disable() { einfo "Removing all the iptables rules to stop redirection to ${name}" disable_redirection_to_moproxy_chain PREROUTING disable_redirection_to_moproxy_chain OUTPUT delete_moproxy_chain } start_post() { enable } stop_pre() { disable } reload() { ebegin "Reloading ${name}" start-stop-daemon --signal HUP --pidfile "$pidfile" iptables --table nat --flush MOPROXY add_noproxy_rules iptables_redirect } ================================================ FILE: pkg/rancher-desktop/assets/scripts/nerdctl ================================================ #!/bin/sh export CONTAINERD_ADDRESS=/run/k3s/containerd/containerd.sock if [ -f /usr/local/openresty/nginx/conf/allowed-images.conf ]; then export HTTPS_PROXY=http://127.0.0.1:3128 fi # On WSL, we need to enter the correct pid &c. namespace for nerdctl to work # correctly. if [ -r /run/wsl-init.pid ]; then parent="$(cat /run/wsl-init.pid)" pid="$(ps -o pid,ppid,comm | awk '$2 == "'"${parent}"'" && $3 == "init" { print $1 }')" if [ -n "${pid}" ]; then exec /usr/bin/nsenter -p -m -n -t "${pid}" /usr/local/libexec/nerdctl/nerdctl "$@" fi fi exec /usr/local/libexec/nerdctl/nerdctl "$@" ================================================ FILE: pkg/rancher-desktop/assets/scripts/nginx.conf ================================================ worker_processes auto; error_log /var/log/openresty/error.log warn; events { worker_connections 1024; } http { map_hash_bucket_size 128; include mime.types; default_type application/octet-stream; log_format proxy escape=json '{' '"access_time":"$time_local",' '"request":"$request",' '"status":"$status",' '"bytes_sent":"$body_bytes_sent",' '"host":"$host",' '"ssl_protocol":"$ssl_protocol",' '"connect_host":"$connect_host",' '"connect_port":"$connect_port",' '}'; log_format mitm escape=json '{' '"access_time":"$time_local",' '"method":"$request_method",' '"uri":"$uri",' '"status":"$status",' '"bytes_sent":"$body_bytes_sent",' '"upstream_response_time":"$upstream_response_time",' '"host":"$host",' '"http_host":"$http_host",' '"upstream":"$upstream_addr"' '}'; server { listen 3128; listen [::]:3128; server_name proxy; access_log /var/log/openresty/proxy.log proxy; proxy_connect; proxy_connect_allow all; proxy_connect_address 127.0.0.1:3129; proxy_max_temp_file_size 0; # response non-CONNECT requests location / { add_header "Content-type" "text/plain" always; return 404 "The Rancher Desktop allowed-images proxy only allows CONNECT requests\n"; } } map "$http_host$uri" $forbidden { default 1; include allowed-images.conf; } # don't limit maximum request size to allow for pushing large image layers client_max_body_size 0; server { listen 3129 ssl default_server; server_name mitm; access_log /var/log/openresty/access.log mitm; # nginx complains if these are not set; we'll clear them again right after ssl_certificate /run/mkcert/localhost.pem; ssl_certificate_key /run/mkcert/localhost-key.pem; ssl_certificate_by_lua_block { local ssl = require "ngx.ssl" local name = ssl.server_name() local ok, err = ssl.clear_certs() if not ok then ngx.log(ngx.ERR, "failed to clear existing (fallback) certificates") return ngx.exit(ngx.ERROR) end local certs_dir = "/run/mkcert/" local cert_file = certs_dir .. name .. ".pem" local key_file = certs_dir .. name .. "-key.pem" local my_load_certificate_chain = function() local f = io.open(cert_file, "rb") if f == nil then local ngx_pipe = require "ngx.pipe" local cmd = { "/usr/bin/mkcert", "-cert-file", cert_file, "-key-file", key_file, name } local opts = { environ = {"CAROOT="..certs_dir}, merge_stderr = true } local proc, err = ngx_pipe.spawn(cmd, opts) if proc == nil then ngx.log(ngx.ERR, "failed to spawn mkcert command: ", err) return ngx.exit(ngx.ERROR) end local data, err, partial = proc:stdout_read_all() local ok, reason, status = proc:wait() if not ok then ngx.log(ngx.ERR, "failed to create cert for ", name, " reason: ", reason, " status: ", status) ngx.log(ngx.ERR, " output: ", data, " err: ", err, " partial: ", partial) return ngx.exit(ngx.ERROR) end f = io.open(cert_file, "rb") end local a = f:read("a*") f:close() return a end local pem_cert_chain = assert(my_load_certificate_chain()) local der_cert_chain, err = ssl.cert_pem_to_der(pem_cert_chain) if not der_cert_chain then ngx.log(ngx.ERR, "failed to convert certificate chain from PEM to DER: ", err) return ngx.exit(ngx.ERROR) end local ok, err = ssl.set_der_cert(der_cert_chain) if not ok then ngx.log(ngx.ERR, "failed to set DER cert: ", err) return ngx.exit(ngx.ERROR) end local my_load_private_key = function() local f = assert(io.open(key_file, "rb")) local a = f:read("a*") f:close() return a end local pem_pkey = assert(my_load_private_key()) local der_pkey, err = ssl.priv_key_pem_to_der(pem_pkey, nil) if not der_pkey then ngx.log(ngx.ERR, "failed to convert private key from PEM to DER: ", err) return ngx.exit(ngx.ERROR) end local ok, err = ssl.set_der_priv_key(der_pkey) if not ok then ngx.log(ngx.ERR, "failed to set DER private key: ", err) return ngx.exit(ngx.ERROR) end } # We need to resolve the real names of our proxied servers. include resolver.conf; # Docker needs this. Don't ask. chunked_transfer_encoding on; proxy_read_timeout 900; # Use SNI during the TLS handshake with the upstream. proxy_ssl_server_name on; proxy_ssl_verify on; proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; proxy_ssl_verify_depth 2; location ~ ^/v[12]/(.+)/manifests/([^/]+)$ { if ($forbidden) { add_header "Content-type" "application/json" always; # `code` from https://github.com/distribution/distribution/blob/main/registry/api/errcode/register.go return 403 "{\"errors\":[{\"code\":\"UNAUTHORIZED\",\"message\":\"image $http_host/$1:$2 is not covered by the Rancher Desktop allowed-images list\"}]}\n"; } proxy_pass https://$http_host; } location / { proxy_pass https://$http_host; } } } ================================================ FILE: pkg/rancher-desktop/assets/scripts/rancher-desktop-guestagent.initd ================================================ #!/sbin/openrc-run # shellcheck shell=ksh depend() { after network-online } GUESTAGENT_LOGFILE="${GUESTAGENT_LOGFILE:-${LOG_DIR:-/var/log}/${RC_SVCNAME}.log}" supervisor=supervise-daemon name="Rancher Desktop Guest Agent" command=/usr/local/bin/rancher-desktop-guestagent command_args=" ${GUESTAGENT_ADMIN_INSTALL:+-adminInstall=${GUESTAGENT_ADMIN_INSTALL}} ${GUESTAGENT_KUBERNETES:+-kubernetes=${GUESTAGENT_KUBERNETES}} ${GUESTAGENT_DOCKER:+-docker=${GUESTAGENT_DOCKER}} ${GUESTAGENT_CONTAINERD:+-containerd=${GUESTAGENT_CONTAINERD}} ${GUESTAGENT_K8S_SVC_ADDR:+-k8sServiceListenerAddr=${GUESTAGENT_K8S_SVC_ADDR}} ${GUESTAGENT_DEBUG:+-debug} " command_args="${command_args//$'\n'/ }" output_log="'${GUESTAGENT_LOGFILE}'" error_log="'${GUESTAGENT_LOGFILE}'" respawn_delay=5 respawn_max=0 start_pre() { cat > /etc/logrotate.d/guestagent <&2 exit 1 fi # If the pid is _not_ /sbin/init, find the child that is. command="$(ps -o pid,args | awk "\$1 == $pid { print \$2 }")" if [ "$command" != "/sbin/init" ]; then newpid="$(ps -o pid,ppid,args | awk "\$2 == $pid && \$3 == \"/sbin/init\" { print \$1 }")" if [ -n "${newpid}" ]; then pid="${newpid}" fi fi if [ $# -eq 0 ]; then set -- /bin/sh fi # If -w$PWD is specified on the first nsenter, then `wsl-exec pwd` # fails with "pwd: getcwd: No such file or directory" exec /usr/bin/nsenter -n -p -m -t "${pid}" /usr/bin/nsenter "-w${PWD}" "$@" ================================================ FILE: pkg/rancher-desktop/assets/scripts/wsl-init ================================================ #!/bin/sh # This script is used to launch (busybox) init on WSL2 through network-setup process. # The network-setup process starts the vm-switch and unshare as its sub processes. this # is necessary since we need to do some mount namespace, since we store the data on the # WSL shared mount (/mnt/wsl/rancher/desktop/) and that can have issues with # lingering tmpfs mounts after we exit. This means we need to run this script # under unshare (to get a private mount namespace), and then we can mark various # mount points as shared (for buildkit). Kubelet will internally do some # tmpfs mounts for volumes (secrets, etc.), which will stay private and go away # once k3s exits, so that we can delete the data as necessary. set -o errexit -o nounset -o xtrace NETWORK_SETUP_LOG="${LOG_DIR}/network-setup.log" VM_SWITCH_LOG="${LOG_DIR}/vm-switch.log" if [ $$ -ne "1" ]; then # This is not running as PID 1; this means that this is a normal invocation # from WSL. exec /usr/local/bin/network-setup --logfile "$NETWORK_SETUP_LOG" \ --vm-switch-path /usr/local/bin/vm-switch --vm-switch-logfile \ "$VM_SWITCH_LOG" ${RD_DEBUG:+-debug} --unshare-arg "${0}" fi # Mark directories that we will need to bind mount as shared mounts. ( IFS=: for dir in / ${DISTRO_DATA_DIRS}; do mount --make-shared "${dir}" done ) # Mount bpffs to allow containers to leverage bpf, and make both bpffs and # cgroupfs shared mounts so the pods can mount them correctly. mount bpffs -t bpf /sys/fs/bpf mount --make-shared /sys/fs/bpf mount --make-shared /sys/fs/cgroup # Mount binfmt_misc to allow nerdctl to see which qemu-* handlers have been loaded. # It will display a warning for foreign platforms if their handler seems missing. mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc mount --make-shared /proc/sys/fs/binfmt_misc if [ -f /var/lib/resolv.conf ]; then ln -s -f /var/lib/resolv.conf /etc/resolv.conf fi # Run init (which never exits). exec /sbin/init ================================================ FILE: pkg/rancher-desktop/assets/specs/README.md ================================================ ## Generators ### To generate go code: `oapi-codegen pkg/rancher-desktop/assets/specs/command-api.yaml > api/commands.go` #### Dependencies: * opai-codegen: To install: ```bash go get github.com/deepmap/oapi-codegen/cmd/oapi-codegen ``` ### To generate documentation: ``` mkdir tmp docker run --rm -v ${PWD}:/local openapitools/openapi-generator-cli:v5.4.2 generate -i /local/src/assets/specs/command-api.yaml -g html -o /local/tmp/ open tmp/index.html # (macOS) start tmp/index.html # (Powershell) xdg-open tmp/index.html # (linux, replace with path to a specific browser if you prefer). ``` Recommended tag: openapitools/openapi-generator-cli:v5.4.2 So run: ``` docker run ... openapitools/openapi-generator-cli@sha256:3d7c84e4b8f25a2074d6ab44d936cd69d08a223021197269e75d29992204e15e ``` ## References: * OpenAPI spec: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#mediaTypeObject * Tools: https://openapi.tools/ ================================================ FILE: pkg/rancher-desktop/assets/specs/command-api.yaml ================================================ info: title: Rancher Desktop API version: 0.0.1 paths: /: get: operationId: listEndpoints summary: List all endpoints. responses: '200': description: A list of endpoints content: application/json: schema: type: array items: { type: string } /v0: get: operationId: listV0Endpoints summary: List all version zero endpoints. responses: '200': description: A list of version 0 endpoints, of which there are none. content: application/json: schema: type: array items: { type: string } /v1: get: operationId: listV1Endpoints summary: List all version one endpoints. responses: '200': description: A list of endpoints content: application/json: schema: type: array items: { type: string } /v1/about: get: operationId: getAbout summary: Returns a description of the endpoints responses: '200': description: A note about endpoints not being forwards-compatible. content: text/plain: schema: type: string /v1/diagnostic_categories: get: operationId: diagnosticCategories summary: Return a list of the category names for the Diagnostics component. Takes no parameters. responses: '200': description: A list of the category names. content: application/json: schema: type: array items: type: string /v1/diagnostic_checks: get: operationId: diagnosticChecks summary: Return all the checks, optionally filtered by specified category and/or checkID. parameters: - in: query name: category - in: query name: checkID responses: '200': description: A list of check objects. An invalid or unrecognized query parameter returns (200, empty array) content: application/json: schema: "$ref" : "#/components/schemas/diagnostics" post: operationId: diagnosticRunChecks summary: Run all diagnostic checks, and return any results. responses: '200': description: A list of check results. content: application/json: schema: "$ref": "#/components/schemas/diagnostics" /v1/diagnostic_ids: get: operationId: diagnosticIDsForCategory summary: >- Return a list of the check IDs for the Diagnostics category, or 404 if there is no such `category`. Specifying an existing category with no checks will return status code 200 and an empty array. parameters: - in: query name: category responses: '200': description: A list of the check IDs for the specified category. content: application/json: schema: type: array items: type: string '404': description: The category is not recognized. /v1/extensions: get: operationId: listExtensions summary: List currently-installed RDX extensions. responses: '200': description: A list of installed RDX extensions. content: application/json: schema: type: object additionalProperties: type: object properties: version: type: string metadata: type: object labels: type: object additionalProperties: type: string '503': description: >- The extension manager has not been loaded yet. The client should retry the request at some future point in time. /v1/extensions/install: post: operationId: installExtension summary: Install an RDX extension parameters: - in: query name: id responses: '201': description: The extension was installed. '204': description: The extension was already installed. '400': description: There was an issue with the parameters. '422': description: The extension could not be installed. content: text/plain: schema: type: string '503': description: An internal error occurred. /v1/extensions/uninstall: post: operationId: uninstallExtension summary: Uninstall an RDX extension parameters: - in: query name: id responses: '201': description: The extension was uninstalled. '204': description: The extension was already uninstalled. '400': description: There was an issue with the parameters. '422': description: The extension could not be installed. content: text/plain: schema: type: string '503': description: An internal error occurred. /v1/factory_reset: put: operationId: factoryReset summary: Factory reset Rancher Desktop, losing user data requestBody: description: JSON block giving factory reset options. content: application/json: schema: type: object properties: keepSystemImages: type: boolean required: true responses: '202': description: The application is performing a factory reset. '400': description: An error occurred /v1/k8s_reset: put: operationId: k8sReset summary: Reset Kubernetes requestBody: description: JSON block giving k8s reset options. content: application/json: schema: type: object properties: mode: type: string enum: [ fast, wipe ] required: true responses: '202': description: The application is performing a Kubernetes reset. '400': description: An error occurred /v1/port_forwarding: post: operationId: createPortForward summary: Create a new port forwarding requestBody: description: JSON block consisting of the port forwarding details content: application/json: schema: type: object properties: namespace: type: string service: type: string k8sPort: type: - string - integer hostPort: type: integer required: true responses: '200': description: The port forwarding was created or already exists; the response contains the listening host port. content: text/plain: schema: type: integer '400': description: The port forwarding could not be created. delete: operationId: deletePortForward summary: Delete a port forwarding parameters: - in: query name: namespace - in: query name: service - in: query name: k8sPort responses: '200': description: The port forwarding was deleted or doesn't exist. '400': description: The port forwarding could not be deleted. /v1/propose_settings: put: operationId: proposeSettings summary: >- Propose some settings and determine if the backend needs to be restarted or reset (losing user data). requestBody: description: >- JSON block consisting of some or all of the current preferences, with changes applied to any number of settings the backend supports changing this way. content: application/json: schema: "$ref" : "#/components/schemas/preferences" required: true responses: '202': description: A description of the effects of the proposed settings on the backend. content: application/json: schema: type: object additionalProperties: type: object properties: current: {} desired: {} severity: type: string enum: [ restart, reset ] '400': description: The proposed settings were not valid. content: text/plain: schema: type: string /v1/settings: get: operationId: listSettings summary: List the current preference settings responses: '200': description: The current preferences in JSON format content: application/json: schema: "$ref" : "#/components/schemas/preferences" put: operationId: updateSettings summary: Updates the specified preference settings requestBody: description: >- JSON block consisting of some or all of the current preferences, with changes applied to any number of settings the backend supports changing this way. content: application/json: schema: "$ref" : "#/components/schemas/preferences" required: true responses: '202': description: The settings were accepted. content: text/plain: schema: type: string '400': description: The proposed settings were not valid. content: text/plain: schema: type: string /v1/settings/locked: get: operationId: listLockedSettings summary: List the current locked settings responses: '200': description: The locked preferences in JSON format content: application/json: schema: "$ref" : "#/components/schemas/preferences" /v1/shutdown: put: operationId: shutdownApp summary: Shuts down Rancher Desktop responses: '202': description: The application is in the process of shutting down. content: text/plain: schema: type: string /v1/snapshots: get: operationId: listSnapshots summary: List the snapshots responses: '200': description: The snapshots list in JSON format content: application/json: schema: type: array items: "$ref" : "#/components/schemas/snapshot" post: operationId: createSnapshot summary: Creates a new snapshot responses: '200': description: The snapshot was created. '400': description: The snapshot could not be created. content: text/plain: schema: type: string delete: operationId: deleteSnapshot summary: Deletes a snapshot parameters: - in: query name: name responses: '200': description: The snapshot was deleted. '404': description: The snapshot could not be deleted. content: text/plain: schema: type: string /v1/snapshots/cancel: post: operationId: cancelSnapshot summary: Cancels active snapshot operation responses: '200': description: The snapshot operation was canceled. '400': description: The snapshot could not be canceled. content: text/plain: schema: type: string /v1/snapshot/restore: post: operationId: restoreSnapshot summary: Restore a snapshot parameters: - in: query name: name responses: '202': description: The snapshot was restored. '404': description: The snapshot could not be restored. content: text/plain: schema: type: string /v1/transient_settings: get: operationId: listTransientSettings summary: List the current transient settings responses: '200': description: The current transient settings in JSON format content: application/json: schema: "$ref" : "#/components/schemas/transientSettings" put: operationId: updateTransientSettings summary: Updates application transient settings requestBody: description: JSON block consisting of transient settings content: application/json: schema: "$ref" : "#/components/schemas/transientSettings" required: true responses: '202': description: The settings were accepted. content: text/plain: schema: type: string '400': description: The proposed transient settings were not valid. content: text/plain: schema: type: string /v1/backend_state: get: operationId: getBackendState summary: Get the current backend state responses: '200': description: The current backend state content: application/json: schema: type: object required: - vmState - locked properties: vmState: type: string locked: type: boolean put: operationId: setBackendState summary: Set the desired backend state requestBody: description: >- JSON block consisting of some or all of desired backend state, with changes applied to the parts of backend state that are specified. content: application/json: schema: type: object properties: vmState: type: string locked: type: boolean required: true responses: '202': description: The desired backend state was accepted. content: text/plain: schema: type: string components: schemas: preferences: type: object properties: application: type: object properties: adminAccess: type: boolean x-rd-platforms: [darwin, linux] # Only in the specified platforms x-rd-usage: enable privileged operations debug: type: boolean x-rd-usage: generate more verbose logging extensions: type: object properties: allowed: type: object properties: enabled: type: boolean x-rd-hidden: true list: type: array items: type: string x-rd-hidden: true installed: type: object x-rd-usage: installed extensions and their tag additionalProperties: type: string pathManagementStrategy: type: string enum: [manual, rcfiles] x-rd-platforms: [darwin, linux] x-rd-usage: update PATH to include ~/.rd/bin telemetry: type: object properties: enabled: type: boolean x-rd-usage: allow collection of anonymous statistics updater: type: object properties: enabled: type: boolean x-rd-usage: automatically update to the latest release autoStart: type: boolean x-rd-usage: start app when logging in startInBackground: type: boolean x-rd-usage: start app without window hideNotificationIcon: type: boolean x-rd-usage: don't show notification icon window: type: object properties: quitOnClose: type: boolean x-rd-usage: terminate app when the main window is closed theme: type: string enum: [system, light, dark] x-rd-usage: set the color theme (system follows OS setting) containerEngine: type: object properties: name: type: string # TODO "docker" setting should be a hidden alias of "moby". # Why have two values for exactly the same thing? enum: [containerd, docker, moby] x-rd-aliases: [container-engine] x-rd-usage: set engine allowedImages: type: object properties: enabled: type: boolean x-rd-usage: only allow images to be pulled that match the allowed patterns patterns: type: array # TODO It is not yet possible to specify array/list values with `rdctl set` x-rd-usage: allowed image names items: type: string mobyStorageDriver: type: string enum: [classic, snapshotter, auto] x-rd-usage: override Moby storage driver selection virtualMachine: type: object properties: memoryInGB: type: integer minimum: 1 x-rd-platforms: [darwin, linux] x-rd-usage: reserved RAM size numberCPUs: type: integer minimum: 1 x-rd-platforms: [darwin, linux] x-rd-usage: reserved number of CPUs type: type: string enum: [qemu, vz] x-rd-platforms: [darwin] useRosetta: type: boolean x-rd-platforms: [darwin] mount: type: object x-rd-platforms: [darwin, linux] properties: type: type: string enum: [reverse-sshfs, 9p, virtiofs] x-rd-usage: how directories are shared; 9p is experimental kubernetes: type: object properties: version: type: string x-rd-aliases: [kubernetes-version] x-rd-usage: choose which version of Kubernetes to run port: type: integer x-rd-usage: apiserver port enabled: type: boolean x-rd-aliases: [kubernetes-enabled] x-rd-usage: run Kubernetes options: type: object properties: traefik: type: boolean x-rd-usage: install and run traefik flannel: type: boolean x-rd-aliases: [flannel-enabled] x-rd-usage: use flannel networking; disable to install your own CNI ingress: type: object properties: localhostOnly: type: boolean x-rd-platforms: [win32] x-rd-usage: bind services to 127.0.0.1 instead of 0.0.0.0 experimental: type: object properties: containerEngine: type: object properties: webAssembly: type: object properties: enabled: type: boolean x-rd-usage: enable support for containerd-wasm shims kubernetes: type: object properties: options: type: object properties: spinkube: type: boolean x-rd-usage: install spin operator virtualMachine: type: object properties: diskSize: type: string x-rd-platforms: [darwin, linux] x-rd-usage: >- desired size of the disk; changing this setting will not shrink existing disks (example: 10GiB) mount: type: object x-rd-platforms: [darwin, linux] properties: 9p: type: object properties: securityModel: type: string enum: [passthrough, mapped-xattr, mapped-file, none] protocolVersion: type: string enum: [9p2000, 9p2000.u, 9p2000.L] msizeInKib: type: integer minimum: 4 x-rd-usage: maximum packet size cacheMode: type: string enum: [none, loose, fscache, mmap] proxy: type: object x-rd-platforms: [win32] x-rd-usage: configure proxy address properties: enabled: type: boolean x-rd-usage: redirect the traffic to the configured proxy address address: type: string x-rd-usage: proxy address password: type: string x-rd-usage: if needed the password to connect to the proxy port: type: integer x-rd-usage: proxy port username: type: string x-rd-usage: if needed the username to connect to the proxy noproxy: type: array x-rd-usage: list of hostname to exclude from using the proxy items: { type: string } sshPortForwarder: type: boolean x-rd-platforms: [darwin, linux] x-rd-usage: use SSH for port forwarding instead of gRPC WSL: type: object x-rd-platforms: [win32] # TODO It is not yet possible to configure this via `rdctl set`. x-rd-usage: make container engine and Kubernetes available in these WSL2 distros properties: integrations: type: object additionalProperties: true portForwarding: type: object properties: includeKubernetesServices: type: boolean x-rd-usage: show Kubernetes system services on Port Forwarding page images: type: object properties: showAll: type: boolean x-rd-usage: show system images on Images page namespace: type: string x-rd-usage: select only images from this namespace (containerd only) containers: type: object properties: showAll: type: boolean x-rd-usage: show system containers on Containers page namespace: type: string x-rd-usage: select only namespaces from this namespace (containerd only) diagnostics: type: object properties: showMuted: type: boolean x-rd-usage: unhide muted diagnostics mutedChecks: type: object # TODO It is not possible to modify this setting via `rdctl set`. x-rd-usage: diagnostic ids that have been muted additionalProperties: true connectivity: type: object properties: interval: type: integer x-rd-usage: >- Number of milliseconds before polling for network access; set this to zero to disable background connectivity checking timeout: type: integer x-rd-usage: Number of milliseconds to wait before timing out diagnostics: type: object properties: last_update: type: string format: date-time example: "1970-01-01T00:00:00.000Z" checks: type: array items: type: object properties: id: type: string category: type: string documentation: type: string description: type: string passed: type: boolean mute: type: boolean fixes: type: array items: type: object properties: description: type: string transientSettings: type: object properties: noModalDialogs: type: boolean preferences: type: object properties: navItem: type: object properties: current: type: string currentTabs: type: object additionalProperties: true ================================================ FILE: pkg/rancher-desktop/assets/styles/app.scss ================================================ @import "./vendor/normalize"; @import "./base/variables"; @import "./base/functions"; @import "./base/mixins"; @import "./base/helpers"; @import "./base/color"; @import "./base/basic"; @import "./base/typography"; @import "./fonts/fontstack"; @import "./fonts/dots"; @import "./fonts/zerowidthspace"; @import "./fonts/icons"; @import "./themes/light"; @media screen and (prefers-color-scheme: dark) { @import "./themes/dark"; } @import './themes/_suse.scss'; @import "./global/columns"; @import "./global/cards"; @import "./global/button"; @import "./global/form"; @import "./global/gauges"; @import "./global/labeled-input"; @import "./global/tooltip"; @import "./global/table"; @import "./global/select"; @import "./global/resource"; @import "./vendor/vue-select"; @import "./rancher-desktop.scss"; ================================================ FILE: pkg/rancher-desktop/assets/styles/base/_basic.scss ================================================ // ----------------------------------------------------------------------------- // This file contains very basic styles. // ----------------------------------------------------------------------------- // HTML { box-sizing: border-box; height: 100%; } BODY { color: var(--body-text); direction: ltr; position: relative; margin: 0; scrollbar-width: thin; scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); &.overflow-hidden { overflow: hidden; } } .dashboard-body { // this was moved to its own class because the background color prop conflicts with storybookjs addon "backgrounds". // decoupling this style allows us to preview components in both light and dark themes background: var(--body-bg); } ::-webkit-scrollbar { width: 8px !important; height: 8px !important; } ::-webkit-scrollbar { width: 8px !important; height: 8px !important; } ::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb) !important; border-radius: var(--border-radius); } ::-webkit-scrollbar-track { background-color: var(--scrollbar-track) !important; } /* * Make all elements from the DOM inherit from the parent box-sizing * Since `*` has a specificity of 0, it does not override the `html` value * making all elements inheriting from the root box-sizing value * See: https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice/ */ *, *::before, *::after { box-sizing: inherit; } :focus, .focused { outline-color: var(--outline); outline-style: solid; outline-width: var(--outline-width); } INPUT, SELECT, TEXTAREA, BUTTON, .btn, .labeled-input, .labeled-select, .unlabeled-select, .checkbox-custom, .radio-custom { &:focus, &.focused { @include form-focus } } button, input, optgroup, select, textarea { margin: var(--outline-width); } A { @include link-color(var(--link), var(--body-text)); text-decoration: none; &:hover, &:active { text-decoration: underline; color: var(--body-text); } } HR { height: 0; border: 0; border-top: 1px solid var(--border); width: 100%; &.dark { border-color: var(--nav-bg); } } HR.vertical { border-top: 0; border-left: 1px solid var(--border); height: 100%; position: absolute; left: 50%; margin-left: -1px; top: 0; } ================================================ FILE: pkg/rancher-desktop/assets/styles/base/_color.scss ================================================ .bg-transparent { background-color: transparent; } .bg-disabled { background-color: var(--disabled-bg) !important; } .text-disabled { color: var(--disabled-text) !important; } ================================================ FILE: pkg/rancher-desktop/assets/styles/base/_functions.scss ================================================ ///Computes the "brightness" of a color @use 'sass:math'; @function brightness($color) { @if type-of($color) == color { @return math.div(red($color) * 0.299 + green($color) * 0.587 + blue($color) * 0.114, 255) * 100%; } @else { @return unquote("brightness(#{$color})"); } } ///Select the more readable foreground color for a given background color. @function contrast-color($color, $dark: $contrasted-dark, $light: $contrasted-light) { @if $color == null { @return null; } @else { $color-brightness: brightness($color); $dark-text-brightness: brightness($dark); $light-text-brightness: brightness($light); @return if(math.abs($color-brightness - $light-text-brightness) > math.abs($color-brightness - $dark-text-brightness), $light, $dark); } } @function add-z-index($key, $value) { @return map-merge($z-indexes, ($key: $value)); } @function z-index($key) { @if map-has-key($z-indexes, $key) { @return map-get($z-indexes, $key); } @warn "Unknown key `#{$key}` in $z-indexes"; @return null; } // _decimal.scss | MIT License | gist.github.com/terkel/4373420 // Round a number to specified digits. // // @param {Number} $number A number to round // @param {Number} [$digits:0] Digits to output // @param {String} [$mode:round] (round|ceil|floor) How to round a number // @return {Number} A rounded number // @example // decimal-round(0.333) => 0 // decimal-round(0.333, 1) => 0.3 // decimal-round(0.333, 2) => 0.33 // decimal-round(0.666) => 1 // decimal-round(0.666, 1) => 0.7 // decimal-round(0.666, 2) => 0.67 // @function decimal-round ($number, $digits: 0, $mode: round) { $n: 1; // $number must be a number @if type-of($number) != number { @warn '#{ $number } is not a number.'; @return $number; } // $digits must be a unitless number @if type-of($digits) != number { @warn '#{ $digits } is not a number.'; @return $number; } @else if not unitless($digits) { @warn '#{ $digits } has a unit.'; @return $number; } @for $i from 1 through $digits { $n: $n * 10; } @if $mode == round { @return math.div(round($number * $n), $n); } @else if $mode == ceil { @return math.div(ceil($number * $n), $n); } @else if $mode == floor { @return math.div(floor($number * $n), $n); } @else { @warn '#{ $mode } is undefined keyword.'; @return $number; } } // Ceil a number to specified digits. // // @param {Number} $number A number to round // @param {Number} [$digits:0] Digits to output // @return {Number} A ceiled number // @example // decimal-ceil(0.333) => 1 // decimal-ceil(0.333, 1) => 0.4 // decimal-ceil(0.333, 2) => 0.34 // decimal-ceil(0.666) => 1 // decimal-ceil(0.666, 1) => 0.7 // decimal-ceil(0.666, 2) => 0.67 // @function decimal-ceil ($number, $digits: 0) { @return decimal-round($number, $digits, ceil); } // Floor a number to specified digits. // // @param {Number} $number A number to round // @param {Number} [$digits:0] Digits to output // @return {Number} A floored number // @example // decimal-floor(0.333) => 0 // decimal-floor(0.333, 1) => 0.3 // decimal-floor(0.333, 2) => 0.33 // decimal-floor(0.666) => 0 // decimal-floor(0.666, 1) => 0.6 // decimal-floor(0.666, 2) => 0.66 // @function decimal-floor ($number, $digits: 0) { @return decimal-round($number, $digits, floor); } @function sizzle-gradient($color) { $angle: 135deg; $startPos: 0%; $start: 0.3; $middlePos: 110px; $middle: 0.1; $endPos: 100%; $end: 0; @return transparent linear-gradient(#{$angle}, #{rgba($color, $start)} #{$startPos}, #{rgba($color, $middle)} #{$middlePos}, #{rgba($color, $end)} #{$endPos} ) 0% 0% no-repeat padding-box; } ================================================ FILE: pkg/rancher-desktop/assets/styles/base/_helpers.scss ================================================ // ----------------------------------------------------------------------------- // This file contains CSS helper classes. // ----------------------------------------------------------------------------- // Text indent, margins, and paddings from 5-50px in 5px increments // e.g. in-10 - {text-indent: 10px} // e.g. p-10 - {padding: 10px} // e.g. mt-20 - {margin-top: 20px} $spacing-property-map: ( m: margin, mt: margin-top, mr: margin-right, ml: margin-left, mb: margin-bottom, p: padding, pt: padding-top, pb: padding-bottom, pl: padding-left, pr: padding-right, in: text-indent, ); @each $keyword, $property in $spacing-property-map { .#{$keyword}-0 { #{$property}: 0 !important; } @for $size from 1 through 10 { $val: $size * 5; .#{$keyword}-#{$val} { #{$property}: $val * 1px !important; } } } .spacer { padding: 40px 0 0 0; } .spacer-small { padding: 20px 0 0 0; } .pull-right { float: right !important; } .pull-left { float: left !important; } /** * Main content containers * 1. Make the container full-width with a maximum width * 2. Center it in the viewport * 3. Leave some space on the edges, especially valuable on small screens */ .container { max-width: $max-width; /* 1 */ min-width: $min-width; margin-left: auto; /* 2 */ margin-right: auto; /* 2 */ padding-left: 20px; /* 3 */ padding-right: 20px; /* 3 */ width: 100%; /* 1 */ } /** * Hide text while making it readable for screen readers * 1. Needed in WebKit-based browsers because of an implementation bug; * See: https://code.google.com/p/chromium/issues/detail?id=457146 */ .hide-text { overflow: hidden; padding: 0; /* 1 */ text-indent: 101%; white-space: nowrap; } /** * Hide element while making it readable for screen readers * https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css#L119-L133 */ .visually-hidden { border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px; } .text-left { text-align: left !important; } .text-center { text-align: center !important; } .text-right { text-align: right !important; } .text-small { font-size: .8em; } .text-normal { font-size: initial; } .text-italic { font-style: italic; } .text-bold { font-weight: bold; } .text-uppercase { text-transform: uppercase; } .text-lowercase { text-transform: lowercase; } .text-capitalize { text-transform: capitalize; } .text-label { color: var(--input-label); } .hide { display: none !important; } .block { display: block !important; } .inline { display: inline !important; } .inline-block { display: inline-block !important; } .table-cell { display: table-cell !important; } .vertical-middle { display: inline-block; vertical-align: middle; } .invisible { visibility: hidden; } .helper-text { font-size: 12px; color: var(--secondary); } // Only display content to screen readers // // See: http://a11yproject.com/posts/how-to-hide-content .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); border: 0; } // Use in conjunction with .sr-only to only display content when it's focused. // Useful for "Skip to main content" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1 // Credit: HTML5 Boilerplate .sr-only-focusable { &:active, &:focus { position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; } } [role="button"] { cursor: pointer; } .eased { -webkit-transition: all 0.5s ease; -moz-transition: all 0.5s ease; -o-transition: all 0.5s ease; transition: all 0.5s ease; } .no-ease { -webkit-transition: none !important; -moz-transition: none !important; -o-transition: none !important; transition: none !important; } .full-height { height: 100%; } .full-width { width: 100%; } .align-top { vertical-align: top !important; } .vertical-scroll { max-height: 150px; overflow: scroll; } .comma-list { span:after { content: ',' } span:last-of-type:after { content: '' } } .link[disabled] { pointer-events: none; } .subtle-box { border: .5px solid var(--subtle-border); box-shadow: 0 0 10px var(--shadow); padding: 20px; margin-bottom: 20px; border-radius: var(--border-radius); } .plus-more { color: var( --input-placeholder ); font-size: 0.8em; } ================================================ FILE: pkg/rancher-desktop/assets/styles/base/_mixins.scss ================================================ // ----------------------------------------------------------------------------- // This file contains all application-wide Sass mixins. // ----------------------------------------------------------------------------- /// Clear inner floats @mixin clearfix() { &:before, &:after { content: " "; // 1 display: table; // 2 } &:after { clear: both; } } @mixin list-unstyled { margin: 0; padding: 0; list-style-type: none; } @mixin no-select { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } @mixin no-resize { resize : none; } @mixin hand { cursor : pointer; cursor : hand; } @mixin fixed { table-layout : fixed; } @mixin clip { text-overflow : ellipsis; overflow : hidden; white-space : nowrap; word-wrap : break-word; } @mixin force-wrap { word-wrap : break-word; white-space: normal; } @mixin bordered-section { border-bottom: 1px solid var(--border); margin-bottom: 20px; padding-bottom: 20px; } @mixin section-divider { margin-bottom: 20px; margin-top: 20px; } .clearfix { @include clearfix; } .list-unstyled { @include list-unstyled } .no-select { @include no-select } .no-resize { @include no-resize } .hand { @include hand } .fixed { @include fixed } .clip { @include clip } .force-wrap { @include force-wrap } .bordered-section { @include bordered-section } .section-divider { @include section-divider } /// Sets the specified background color and calculates a dark or light contrasted text color. @mixin contrasted($background-color, $dark: $contrasted-dark, $light: $contrasted-light) { color: contrast-color($background-color, $dark, $light); &:hover { text-decoration: underline; color: var(--body-text); } } /// Sets base color and darkens bg on hover @mixin bg-lighten($bg) { background: $bg; * { background:lighten($bg,20%); } } @mixin link-color($color, $hover) { @if not($hover) { $hover: $color; } color: $color; &:hover { text-decoration: underline; color: $hover; } } @mixin icon-rotate($degrees, $rotation) { filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=#{$rotation}); -webkit-transform: rotate($degrees); -ms-transform: rotate($degrees); transform: rotate($degrees); } @mixin icon-flip($horiz, $vert, $rotation) { filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=#{$rotation}); -webkit-transform: scale($horiz, $vert); -ms-transform: scale($horiz, $vert); transform: scale($horiz, $vert); } @mixin input-status-color { &:not(.focused) { &.success { border: solid 1px var(--success); input, .selected { color: var(--success); } .vs__actions:after { color: var(--success); } } &.warning { border: solid 1px var(--warning); input, .selected { color: var(--warning); } .vs__actions:after { color: var(--warning); } } &.error { border: solid 1px var(--error); input, .selected { color: var(--error); } .vs__actions:after { color: var(--error); } } } } @mixin form-focus { // Focus for form like elements (not to be confused with basic :focus style) outline: none; box-shadow: 0 0 0 var(--outline-width) var(--outline); background: var(--input-focus-bg) } ================================================ FILE: pkg/rancher-desktop/assets/styles/base/_typography.scss ================================================ HTML, BODY { font-family: $body-font; font-size: 14px; } H1, H2, H3, H4, H5, H6 { color: var(--body-text); font-style: normal; font-weight: 400; margin: 0 0 10px 0; } H1 { font-size: 24px; } H2 { font-size: 21px; } H3 { font-size: 18px; } H4 { font-size: 16px; } H5 { font-size: 14px; } H6 { font-size: 12px; text-transform: uppercase; } P { font-weight: 400; font-style: normal; margin: 0; } //code code, samp, kbd, .monospace { font-family: $mono-font; text-align: left; } pre { padding: 10px; border-radius: var(--border-radius); background: var(--box-bg); margin: 5px; overflow: auto; } code { background-color: var(--box-bg); display: inline-block; padding: 5px; border: 1px solid var(--border); border-radius: var(--border-radius); } .v-popper__popper code, pre code { background: transparent; padding: 0; } ================================================ FILE: pkg/rancher-desktop/assets/styles/base/_variables.scss ================================================ $header-font: 'Poppins', sans-serif; $body-font: 'Lato', arial, helvetica, sans-serif; $mono-font: 'Roboto Mono', monospace; $max-width: 1440px !default; $min-width: 75% !default; $input-height: 61px; $unlabeled-input-height: 40px; $input-padding-lg: 18px; $input-padding-sm: 10px; $input-line-height: 18px; $column-gutter: 1.75%; $sideways-tabs-width: 200px; $array-list-remove-margin: 75px; $z-indexes: ( zero: 0, default: 1, overContent: 2, hoverOverContent: 3, tableGroup: 10, fixedTableHeader: 11, modalOverlay: 20, modalContent: 21, tooltip: 30, dropdownOverlay: 40, dropdownContent: 41, loadingOverlay: 50, loadingContent: 51 ); // Usage Example: // @media only screen and (min-width: map-get($breakpoints, '--viewport-*')) { // } $breakpoints: ( '--viewport-4': 480px, '--viewport-7': 768px, '--viewport-9': 992px, '--viewport-12': 1281px, ); ================================================ FILE: pkg/rancher-desktop/assets/styles/fonts/_dots.scss ================================================ @font-face { font-family: 'dotsfont'; font-weight: normal; font-style: normal; src: url(data:application/font-woff;charset=utf-8;base64,d09GRgABAAAAAAyEABEAAAAAV7gAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAABgAAAABwAAAAcgM9Wv0dERUYAAAGcAAAAHQAAAB4AJwDeT1MvMgAAAbwAAABMAAAAYHOnumtjbWFwAAACCAAAAUwAAAGSId/p/mN2dCAAAANUAAAADAAAAAwF+BA6ZnBnbQAAA2AAAAGxAAACZVO0L6dnYXNwAAAFFAAAAAgAAAAIAAAAEGdseWYAAAUcAAABlQAARtxIEfQVaGVhZAAABrQAAAAyAAAANhPqDEtoaGVhAAAG6AAAAB4AAAAkD9kKlmhtdHgAAAcIAAAATAAAA2Cb0mq3bG9jYQAAB1QAAAGfAAABsumd1/5tYXhwAAAI9AAAACAAAAAgAfIAQm5hbWUAAAkUAAABRwAAApIWY2jUcG9zdAAAClwAAAHJAAACkyUTPVZwcmVwAAAMKAAAAFIAAABSWo1sY3dlYmYAAAx8AAAABgAAAAaskFpGAAAAAQAAAADV7pT1AAAAANR0ZLoAAAAA1mxdD3jaY2BkYGDgAWIxIGZiYATC60DMAuYxAAAM2wEGAAAAeNpjYOaUY5zAwMrAwmrMcpaBgWEWhGY6y5DGlAbkA6XggJkBCYR6h/sxODAoqP5hY/gH5LMxMGkoMDAwguQYvzC9A1IKDIwAF8ELN3jaY2BgYGaAYBkGRgYQ6AHyGMF8FoYCIC3BIAAU4WCoY9jC8J/pGNMdBS4FEQVJBX2FeNU///8DVSgwLGDYBpZhUBBQkIDJ/H/8/9D/g39//3324PCDfQ92P1j2oPzWbagtWAEjGwNcmpEJSDChKwA6lYWVjZ2Dk4ubh5ePX0BQSFhEVExcQlJKWkZWTl5BUUlZRVVNXUNTS1tHV0/fwNDI2MTUzNzC0sraxtbO3sHRydnF1c3dw9PL28fXzz8gMCg4JDQsPCIyKjomNi4+IZGhta2ja9L0uYsWLl66ZNmKVStXr1m3dv2GTVs2b92+bfeuPXsZilJSMxnKFxRkM5RlMbTPZChmYEiHuC6nmmH5zobkPBA7t4YhqbFlGgPDxUsMDJev7GA4APNDBRA3dzf1dPb1T+idMpVh8uw5sxgOHioEClcCMQAKvGiaAAAFdQW0BbQARAUReNpdUbtOW0EQ3Q0PA4HE2CA52hSzmZDGe6EFCcTVjWJkO4XlCGk3cpGLcQEfQIFEDdqvGaChpEibBiEXSHxCPiESM2uIojQ7O7NzzpkzS8qRqnfpa89T5ySQwt0GzTb9Tki1swD3pOvrjYy0gwdabGb0ynX7/gsGm9GUO2oA5T1vKQ8ZTTuBWrSn/tH8Cob7/B/zOxi0NNP01DoJ6SEE5ptxS4PvGc26yw/6gtXhYjAwpJim4i4/plL+tzTnasuwtZHRvIMzEfnJNEBTa20Emv7UIdXzcRRLkMumsTaYmLL+JBPBhcl0VVO1zPjawV2ys+hggyrNgQfYw1Z5DB4ODyYU0rckyiwNEfZiq8QIEZMcCjnl3Mn+pED5SBLGvElKO+OGtQbGkdfAoDZPs/88m01tbx3C+FkcwXe/GUs6+MiG2hgRYjtiKYAJREJGVfmGGs+9LAbkUvvPQJSA5fGPf50ItO7YRDyXtXUOMVYIen7b3PLLirtWuc6LQndvqmqo0inN+17OvscDnh4Lw0FjwZvP+/5Kgfo8LK40aA4EQ3o3ev+iteqIq7wXPrIn07+xWgAAAAABAAH//wAPeNrt2jFLI0EUwPH3dncSBeVcOWxssnE97ggYTYxVOlNa2PgNLPwOVgpiYXNe5XewMLOoYDiOuxPtQsDCWGhhuYiFrcrgrkW6a2yE888w8OYx7/emfyOetES8VbMivhRlxqpUm0kxCO5rtmCum4nvZaFYP0+bPJ0UC+a5mWier4dROB2FUcsruVj33JpZedxvBV0RUe3LSHHHbMgnmZR2ULUjmmp7rGpFK3bIS22oFZmd08/jQb0WjzfmvXiqHHja7+hSr6fLvzvusNtzB3+8X6fu/OhEF8/+avO4435iY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2Njv7MtbwH4W4aNjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY39oebC726Y/sCYqNcWcuPLVLmQGZvp7o+77c30++799r/rhwf1EkSlWBrzEkelQLSvRi+0nK0rHXVPbsbduhv31T38X7Py/PFmy2yJL98kyQ4Vq5oOAs3ateXS+lmXfJvXTnNhFOZFj+vZvReYPphnAAAAeNpjYGRgYADiTd8OXoznt/nKIM/BAAJXSlJ2gehrObH8IJrzOmsrkOJgYALxAFBACosAAHjaY2BkYGBj+HuDgYH7KQMQcF5nYGRABTcAYDIEhgAAeNpjesPgwgAETKsYGDhngjDj9VE8EHg07AcaMx1iYGBtBeYFKM14HYgTgZnjNRRvAPKlgLQfalyxP2W8zv0UwQepAekDmQEADJUueHjadcI7KIQBAADg//1+v98vkiQZJEmSDJIkXZcuGS5J0iXDdYMkSZcMMkiSDDJckiTJIIOkSwYZJF2XdBl0SZckA8mq7wMAoP5PAsgCR8ATKIEdYArcAPPgB1QDxaA5aB8qwAzcCo/Bq/A5/IYESB8yjeSQOxRDm9Akuoyeoi+YhXVjaWwbu8EBvAEfwhfxY7xEKEQnMUlsElfEJ1lLxsl58oAsUhzVRo1Ta9QFVaEjup+eoXfpe4ZgmpkRZoU5Y8qsw/awGXaHveUgrpEb5pa4E+6Z1/guforf4q/5L6FOGBQWhEPhURR+tIsT4rp4Kb5L1dKANCvtSQ8yJbfICTkrH8lPiqR0KCllQ8krH2qNGlPn1H21oDFaqzamrWrn2pse6H36tJ7T7wzMaDKSxrJxaryYltltps1t88YCrAZryFq0jq2Srdid9qS9aV/Zn06tE3fmnQOn6HJumzvurrkXbsWLvH5vxtv17n3Cb/ZH/BX/zC8HTtATZIKd4DaEwsZwOFwKT8LnSIu6/jEaZaNclI9eq7yq3l+pb5RddPEAAAEAAADYABAAAgAAAAAAAgABAAIAFgAAAQAALgAAAAB42oWRu0oDQRSGvzFRiEjURiTVFrbGRIjXSpQ0gkVE05rLGqMbL9kkoE/gkwj2VhapvTyBjc9h6b+zQxIWQZaZ8838Z/5zZhaY55UUJp0BLjViNixoFfMUWR4cp9jg0XEaj6HjaXJ8O57R/o/jDEtm2fEcKybveJGsqTh+U86Z43cKZuD4QzlPjj+ZNS8xf6XImSH73HDLPV3atLigp6rPGusUKKpLj7pUj0PNAb6oqjkgtPt5rfe0ChTHDqFd+Yq+4kBzU5lNVYq0c8VrUUX7Lfo6W1NWURkF++1yompVjkTJM6vWd3wqqXsJ11PbQajOIt2bqPKf89/3jW7X03uF7LCmryPtymXmacirk/CJTtcn+os7KNvX8jiQ2rCvvW21knxLbNm5NPoLm+rWl0fNuvbk1XU3Ko98j7mT2pYS1Q9+AfhBY1UAeNpt0UdMVHEUxeHfpczA0DvYFUFRwPfeMBT7DDhYUGyISlEUmBlFRHBUbGgssUSCMWEHEXWjiZpoosaEFQsVe0ACLlzbMCzUnYno+7vzbL7kLM7iXgL4m19e6vlfhkECJJBAggjGgpUQQrERRjgRRBJFNDHEEkc8CSSSRDIpTGAik5jMFKYyjenMIJWZpJHOLGaTwRzmkkkW2cxDQ8fATg4OcskjnwLms4CFLGIxS1iKExeFFLEMN8UsZwUrWUUJq1lDKWtZx3o2sJEyNlHOZrawlQoqqaKabWynRoK4zmnO0EsnHzlLOxfp4iY3JJgLvOcUV8QiVi5xjj4+SAjd3OIH3/nJNW7zjCfcYQc76aCW59TxlH5e84KXvOLT+O0GeMNb7uJhjMsMMcg7vHxhlPPswsdu9tBAI1fZyz6aaKYFP/s5wEE+c4jDtHKEYxzlET20cZwTnOQr33g8/oMRCRWbhEm4REikREm0xEisxEm8JEgi97jPAx5KkiRLisXT0Nrk1U0Mq7/Rp2lOTVlk6lK9y64s+KOhaZpSVxpKuzJH6VDmKvOU+cp/e05TXe3quq3e5/E319XWtHjNynCbOkwd7sLfiLiJTAAAALgB/4WwAY0AS7AIUFixAQGOWbFGBitYIbAQWUuwFFJYIbCAWR2wBitcWACwASBFsAMrRAGwAiBFsAMrRLADIEW6AAJ//wACK7EDRnYrRFmwFCsAAAABWkasjwAA) format('woff'); } .conceal:not(:invalid):not(:focus) { font-family: 'dotsfont' !important; font-size: 6px; } .conceal PRE { font-family: 'dotsfont' !important; font-size: 10px; } ================================================ FILE: pkg/rancher-desktop/assets/styles/fonts/_fontstack.scss ================================================ /* poppins-300 - latin */ @font-face { font-family: 'Poppins'; font-style: normal; font-weight: normal; src: local(''), url('@pkg/assets/fonts/poppins/poppins-v15-latin-300.woff2') format('woff2'), /* Super Modern Browsers */ url('@pkg/assets/fonts/poppins/poppins-v15-latin-300.woff') format('woff'), /* Modern Browsers */ } /* poppins-500 - latin */ @font-face { font-family: 'Poppins'; font-style: normal; font-weight: bold; src: local(''), url('@pkg/assets/fonts/poppins/poppins-v15-latin-500.woff2') format('woff2'), /* Super Modern Browsers */ url('@pkg/assets/fonts/poppins/poppins-v15-latin-500.woff') format('woff'), /* Modern Browsers */ } /* lato-regular - latin */ @font-face { font-family: 'Lato'; font-style: normal; font-weight: normal; src: local(''), url('@pkg/assets/fonts/lato/lato-v17-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ url('@pkg/assets/fonts/lato/lato-v17-latin-regular.woff') format('woff'), /* Modern Browsers */ } /* lato-700 - latin */ @font-face { font-family: 'Lato'; font-style: normal; font-weight: bold; src: local(''), url('@pkg/assets/fonts/lato/lato-v17-latin-700.woff2') format('woff2'), /* Super Modern Browsers */ url('@pkg/assets/fonts/lato/lato-v17-latin-700.woff') format('woff'), /* Modern Browsers */ } /* roboto-mono-regular - latin */ @font-face { font-family: 'Roboto Mono'; font-style: normal; font-weight: normal; src: local(''), url('@pkg/assets/fonts/roboto-mono/roboto-mono-v13-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ url('@pkg/assets/fonts/roboto-mono/roboto-mono-v13-latin-regular.woff') format('woff'), /* Modern Browsers */ } ================================================ FILE: pkg/rancher-desktop/assets/styles/fonts/_icons.scss ================================================ @use 'sass:math'; @import "~rancher-icons/style.scss"; // Animated Icons // -------------------------- .icon-spin { -webkit-animation: icon-spin 5000ms infinite linear; animation: icon-spin 5000ms infinite linear; } @-webkit-keyframes icon-spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } } @keyframes icon-spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } } // FontAwesomeness $icon-li-width: math.div(30em, 14) !default; $icon-inverse: #fff !default; .icon { display: inline-block; } // Sizes .icon-fw { width: math.div(18em, 14); text-align: center; } .icon-sm { font-size: (1em*0.8); } .icon-lg { font-size: math.div(4em, 3); line-height: (3em * 0.25); vertical-align: -15%; } .icon-2x { font-size: 2em; } .icon-3x { font-size: 3em; } .icon-4x { font-size: 4em; } .icon-5x { font-size: 5em; } // Stacked .icon-stack { position: relative; display: inline-block; // width: 2em; height: 2em; line-height: 2em; vertical-align: middle; } .icon-stack-1x, .icon-stack-2x { position: absolute; left: 0; width: 100%; text-align: center; } .icon-stack-1x { line-height: inherit; } .icon-stack-2x { font-size: 2em; } .icon-inverse { color: $icon-inverse; } // List .icon-ul { padding-left: 0; margin-left: $icon-li-width; list-style-type: none; > li { position: relative; } } .icon-li { position: absolute; left: -$icon-li-width; width: $icon-li-width; top: math.div(2em, 14); text-align: center; &.icon-lg { left: -$icon-li-width + math.div(4em, 14); } } .icon-rotate-90 { @include icon-rotate(90deg, 1); } .icon-rotate-180 { @include icon-rotate(180deg, 2); } .icon-rotate-270 { @include icon-rotate(270deg, 3); } .icon-flip-horizontal { @include icon-flip(-1, 1, 0); } .icon-flip-vertical { @include icon-flip(1, -1, 2); } ================================================ FILE: pkg/rancher-desktop/assets/styles/fonts/_zerowidthspace.scss ================================================ // Zero-width space font, for killing space between inline-block elements @font-face { font-family: 'zerowidthspace'; font-weight: normal; font-style: normal; src: url(data:application/font-woff;charset=utf-8;base64,d09GRgABAAAAAAakABEAAAAACYwAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAABgAAAABwAAAAceSKT/EdERUYAAAGcAAAAIgAAACYAJwAsT1MvMgAAAcAAAABKAAAAYGJnjp9jbWFwAAACDAAAAEoAAAFSBLks8GN2dCAAAAJYAAAAAgAAAAIAAAAAZnBnbQAAAlwAAAGxAAACZVO0L6dnYXNwAAAEEAAAAAgAAAAIAAAAEGdseWYAAAQYAAAANgAAADg03gskaGVhZAAABFAAAAA0AAAANgPvJ9JoaGVhAAAEhAAAAB4AAAAkBEYD32htdHgAAASkAAAAFQAAABgKqv8zbG9jYQAABLwAAAAOAAAADgBEADxtYXhwAAAEzAAAAB8AAAAgASAADG5hbWUAAATsAAABUgAAAq4ayl+KcG9zdAAABkAAAAAsAAAAPmMjcrlwcmVwAAAGbAAAAC4AAAAusPIrFHdlYmYAAAacAAAABgAAAAa9JVnfAAAAAQAAAADUUbVqAAAAAM7LcO4AAAAA1gVto3jaY2BkYGDgAWIxBjkGJgZGIGQFYhagCBMQM0IwAAipAFQAAHjaY2BhYWD8wsDKwMJqzDqDgYFRHkIzX2VIYRJgYGBiYGNmgAEECwgC0lxTGA4wKKj+YUv7l8bAwOLCoAEUZkRSosDACADyugnvAAB42mNgYGBmgGAZBkYGEPAB8hjBfBYGAyDNAYRMQFqBYYHqn///Eaz/j/+n3OKE6gIDRjYGOJcRpIeJARUwQqwaGoCFLF0AOm0M0gAAAAAAAHjaXVG7TltBEN0NDwOBxNggOdoUs5mQxnuhBQnE1Y1iZDuF5QhpN3KRi3EBH0CBRA3arxmgoaRImwYhF0h8Qj4hEjNriKI0Ozuzc86ZM0vKkap36WvPU+ckkMLdBs02/U5ItbMA96Tr642MtIMHWmxm9Mp1+/4LBpvRlDtqAOU9bykPGU07gVq0p/7R/AqG+/wf8zsYtDTT9NQ6CekhBOabcUuD7xnNussP+oLV4WIwMKSYpuIuP6ZS/rc052rLsLWR0byDMxH5yTRAU2ttBJr+1CHV83EUS5DLprE2mJiy/iQTwYXJdFVTtcz42sFdsrPoYIMqzYEH2MNWeQweDg8mFNK3JMosDRH2YqvECBGTHAo55dzJ/qRA+UgSxrxJSjvjhrUGxpHXwKA2T7P/PJtNbW8dwvhZHMF3vxlLOvjIhtoYEWI7YimACURCRlX5hhrPvSwG5FL7z0CUgOXxj3+dCLTu2EQ8l7V1DjFWCHp+29zyy4q7VrnOi0J3b6pqqNIpzftezr7HA54eC8NBY8Gbz/v+SoH6PCyuNGgOBEN6N3r/orXqiKu8Fz6yJ9O/sVoAAAAAAQAB//8AD3jaY2D6b8zAwHCWxYWBmYGdgUFdUFGQ1VhQ+SzjrH9nz5xJYy7/05kGlGRgZEAChgwAYkELOgAAeNpjYGRgYABincimc/H8Nl8ZuDkYQODc6YJ3IPoaa+7i/8ZAxlkWFyDJwcAEEgUAM5AKvXjaY2BkYGBx+X8DSDL8N2ZgYDjLABRBAWwAa5AEKwAAeNpjYfhvzAAETKsY4IAFiAEktwHnAAAAAAAAFAAUABQAFAAUABwAAHjaY2BkYGBgY+BgYGIAASYGRiAWY2BgZIAAAAMcAC4AeNqFUctKw0AUPWOqYBGXLlyUWSrYGCtW7LbQhSAWKgruUjuxkfpKUsQu/QjX4ke4dqnVH/AH/ALX4snMNShCZZg7594599yTCYA53MKDKs0CuOd2WKHCzOEpch4FezjBq+ASVlRd8DS06gqeQUXdCH7CgroT/IxAPQgeY169C35BWX04/OZhUX2iiQQGITLGHjS6uGbcZiXFOc6I2/AZm8yO0Cc7ZNWg/KfzCjFxn6hlOzN7JjjmvUaNKgHPJTIyrgs0sMoVCTcquD4nR4z5lAzLGPFMeJvr9+yElN0h3RiiHTs9xhCnE+c2uA9FqYqDQkuj80NNiw9NhuFbuL4tdqxRqcodEK3/40n/crVvuSkz956B1fDtmTubrOV8fL+Sls49sobMdsly1ZqNG/QboM7oKs7vJnUNNUL2DMjPv9C5aRW6HVzyNuZN/lcHX4j5amYAAHjaY2BiAIO/5xnSGLABNiBmZGBiYGZkYmRmL83LNDBwNADRRqZuzgCcZgavuAH/hbABjQBLsAhQWLEBAY5ZsUYGK1ghsBBZS7AUUlghsIBZHbAGK1xYWbAUKwAAAAFZ370kAAA=) format('woff'); } ================================================ FILE: pkg/rancher-desktop/assets/styles/global/_button.scss ================================================ $btn-padding: 0 21px 0 21px; $btn-sm-padding: 0 7px 0 7px; $btn-height: 40px; $btn-sm-height: 30px; // ----------------------------------------------------------------------------- // This file contains all styles related to the button component. // ----------------------------------------------------------------------------- .btn, button, [class^='btn-'] { display: inline-block; text-align: center; white-space: nowrap; vertical-align: middle; cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; border: 0; padding: $btn-padding; border-radius: var(--border-radius); color: var(--lightest); line-height: $btn-height; min-height: $btn-height; &:hover { text-decoration: none; color: var(--lightest); } &.bg-transparent { color: var(--body-text); } } //icon button .icon-btn { padding: 0; line-height: initial; &.btn-sm { padding: 0; } span { padding: 0 10px 0 5px; vertical-align: middle; } } .btn-sm, .btn-group-sm > .btn, .btn-sm .btn-label { padding: $btn-sm-padding; min-height: $btn-sm-height; line-height: 28px; } //btn roles .role-primary { background: var(--primary); color: var(--primary-text); &:hover { background-color: var(--primary-hover-bg); color: var(--primary-text); } &:focus { background-color: var(--primary-hover-bg); color: var(--primary-text); } } .role-secondary { background: transparent; color: var(--primary) !important; border: solid 1px var(--primary); line-height: $btn-height - 2px; } .role-tertiary { background: var(--accent-btn); border: solid 1px var(--primary); color: var(--primary); } .role-danger { background: var(--error); &:hover { background-color: var(--error-hover-bg); } } .role-link { background: transparent; color: var(--link) !important; } .role-multi-action { background: var(--accent-btn); border: solid thin var(--primary); color: var(--primary); border-radius: 2px; } .icon-group i { font-size: 1.5em; } //disabled .btn-disabled, .btn.disabled, .btn[disabled], fieldset[disabled] .btn { cursor: not-allowed; color: var(--disabled-text) !important; &:not(.role-link){ background-color: var(--disabled-bg) !important; } } .btn-group { position: relative; text-align: initial; vertical-align: middle; padding: 0; border-radius: var(--border-radius); .btn { position: relative; display: inline-block; border-radius: 0; text-align: center; &:focus { // Move the focused one to the top so that the focus ring is all visible z-index: 1; } &.active { @extend .bg-primary; } &:first-child { border-top-left-radius: var(--border-radius); border-bottom-left-radius: var(--border-radius); } &:last-child { border-top-right-radius: var(--border-radius); border-bottom-right-radius: var(--border-radius); } } .btn[disabled] { // Ensure disabled button's border remains as-is; otherwise, button appears vertically shorter than others in group border: inherit; } } ================================================ FILE: pkg/rancher-desktop/assets/styles/global/_cards.scss ================================================ .links { display: flex; flex-wrap: wrap; width: 100%; .link-container { position: relative; background-color: var(--input-bg); border-radius: var(--border-radius); border: solid 1px var(--input-border); display: flex; flex-basis: 40%; margin: 0 10px 10px 0; max-width: 325px; min-height: 100px; border-left: solid 10px var(--primary); a[disabled], &.disabled { cursor: not-allowed; color: var(--disabled-text); } &.disabled{ background-color: var(--disabled-bg); border-left: solid 10px var(--disabled-text); > * { opacity: .3; } .disabled-msg{ position:absolute; color: var(--error); z-index: z-index('hoverOverContent'); opacity: 1; top: 0px; bottom: 0px; left: 0px; right: 0px; display: flex; justify-content: center; align-items: flex-end; } } &:hover:not(.disabled) { box-shadow: 0px 0px 1px var(--outline-width) var(--outline); } > * { align-items: center; display: flex; flex: 1 0; padding: 10px; .link-logo, .link-content { display: inline-block; height: 100%; } .link-logo { text-align: center; width: 60px; height: 60px; border-radius: calc(2 * var(--border-radius)); background-color: white; img { width: 56px; height: 56px; -o-object-fit: contain; object-fit: contain; position: relative; top: 2px; } } .link-content { width: 100%; margin-left: 10px; } .description { margin-top: 10px; display: -webkit-box; -webkit-box-orient: vertical; // -webkit-line-clamp: 3; // line-clamp: 3; text-overflow: ellipsis; color: var(--secondary); } } } } ================================================ FILE: pkg/rancher-desktop/assets/styles/global/_columns.scss ================================================ @use 'sass:math'; @import "@pkg/assets/styles/base/_functions"; @import "@pkg/assets/styles/base/_variables"; $COLUMNS: 6 10 11 12 23 24; $DEFAULT_COLUMNS: 12; /* SECTIONS */ .section { clear: both; padding: 0px; margin: 0px; } /* COLUMN SETUP */ .col { flex: 0 0 auto; margin: 0 $column-gutter 0 0; &:last-child { margin-right: 0; } &.equal-height { display: flex; flex: 1; } } /* ROWS */ .row { display: flex; // margin-bottom: 20px; } .row:before, .row:after { content:""; display:table; } .row:after { clear:both; } /* COLUMNS */ @each $cols in $COLUMNS { $span: $cols; $suffix: null; @if ( $cols != $DEFAULT_COLUMNS ) { $suffix: -of-#{$cols} } @while $span > 0 { // Normal column with a gutter .span-#{$span}#{$suffix} { width: decimal-round( (math.div(100 - ($column-gutter * ($cols - 1)), $cols) * $span) + (($span - 1) * $column-gutter) , 3, 'floor'); } // Gutterless column .gutless .span-#{$span}#{$suffix} { margin-right: 0; width: decimal-round( math.div($span, $cols) * 100%, 3, 'floor'); } // Offsets .offset-#{$span}#{$suffix} { margin-left: decimal-round( (math.div($span, $cols)*100%) + $span*math.div($column-gutter, $cols), 3, 'floor' ); } // Gutterless offset .gutless .offset-#{$span}#{$suffix} { margin-left: decimal-round( (math.div($span, $cols)*100%), 3, 'floor' ); } $span: $span - 1; } } //flex grid .container-flex { display: flex; flex-wrap: wrap; .flex-item-half { flex-grow: 1; display: flex; width: 50%; } } .flex-justify-center { justify-content: center; } .flex-justify-right { justify-content: right; } .flex-justify-left { justify-content: left; } .container-flex-center { @extend .container-flex; align-items: center; } // Equal height columns .row-full-height { height: 100%; } .col-full-height { height: 100%; vertical-align: middle; } .row-same-height { display: table; width: 100%; /* fix overflow */ table-layout: fixed; } //breakpoint stuff here ================================================ FILE: pkg/rancher-desktop/assets/styles/global/_form.scss ================================================ $spacing: 10px; INPUT:not([type]), INPUT[type='text'], INPUT[type='password'], INPUT[type='number'], INPUT[type='date'], INPUT[type='email'], INPUT[type='search']:not(.vs__search), INPUT[type='tel'], INPUT[type='url'], SELECT, TEXTAREA, .labeled-input, .labeled-select, .unlabeled-input, .unlabeled-select { position: relative; display: block; box-sizing: border-box; width: 100%; opacity: 1; padding: $input-padding-sm; background-color: var(--input-bg); border-radius: var(--border-radius); border: solid var(--border-width) var(--input-border); color: var(--input-text); @include input-status-color; LABEL { color: var(--input-label); } &:hover { &, .vs__dropdown-menu { background: var(--input-hover-bg); } } &::placeholder { color: var(--input-placeholder); } &.disabled, &.disabled .selected, &[disabled], &[disabled]:hover, &.view { color: var(--input-disabled-text); background-color: var(--input-disabled-bg); outline-width: 0; border-color: var(--input-disabled-border); cursor: not-allowed; label { color: var(--input-disabled-label); display: inline-block; z-index: 1; } &::placeholder { color: var(--input-disabled-placeholder); } } LABEL { margin: $spacing 0 0 0; } } INPUT[type='search']:not(.vs__search) { padding: calc(#{$input-padding-sm} + 2px); } TEXTAREA { padding: $input-padding-lg 10px 10px 10px; line-height: $input-line-height; } FORM { LABEL { color: var(--input-label); display: inline-block; margin: $spacing 0 $spacing 0; font-size: 12px; .radio-label, .checkbox-label { font-size: 14px; } &.radio, &.checkbox { cursor: pointer; margin: 5px 0; > INPUT { margin-right: 5px; } } &.radio + LABEL.radio, &.checkbox + LABEL.checkbox { margin-left: 20px; } } .actions { padding-top: $spacing; } .detail { margin-top: 2px; @extend .text-small; color: var(--muted); } .group { border: 1px solid var(--input-border); padding: 20px; } } .field-required { color: var(--error); font-weight: bold; } INPUT.inline-input { display: inline-block; width: 75px; margin: 0 10px; } .input-title { clear: both; margin-left: 24px; font-size: 12px; } .fixed select, .fixed.v-select, .fixed input:not(.vs__search){ height: 50px; } ================================================ FILE: pkg/rancher-desktop/assets/styles/global/_gauges.scss ================================================ @media only screen and (min-width: map-get($breakpoints, "--viewport-7")) { .resource-gauges { grid-template-columns: 1fr 1fr; } .hardware-resource-gauges { &, &.live { grid-template-columns: 1fr; } } } @media only screen and (min-width: map-get($breakpoints, "--viewport-9")) { .resource-gauges { grid-template-columns: 1fr 1fr 1fr; } .hardware-resource-gauges { grid-template-columns: 1fr 1fr 1fr; &.live { grid-template-columns: 1fr 1fr; } } } @media only screen and (min-width: map-get($breakpoints, "--viewport-12")) { .resource-gauges { grid-template-columns: 1fr 1fr 1fr; } } .resource-gauges { display: grid; grid-column-gap: 10px; grid-row-gap: 15px; margin-top: 25px; & > * { width: 100%; height: 100%; } } .hardware-resource-gauges { display: grid; grid-column-gap: 15px; grid-row-gap: 20px; &:first-of-type { margin-top: 35px; } & > * { width: 100%; } } ================================================ FILE: pkg/rancher-desktop/assets/styles/global/_labeled-input.scss ================================================ .labeled-input { position: relative; display: table; border-collapse: separate; min-height: $input-height; LABEL { position: absolute; transform: translate(0, -10px) scale(1); transform-origin: top left; transition-property: transform, font-size; transition-duration: 0.1s; transition-timing-function: ease-in-out; color: var(--input-label); pointer-events: none; i { pointer-events: initial; } } .corner { top: 5px; right: 10px; margin: 0; padding: 0; text-align: right; z-index: 3; transform: none !important; } .required { color: var(--error); } INPUT, SELECT { position: relative; font-size: 14px; display: block; width: 100%; } SELECT.empty { color: var(--input-placeholder); } SELECT { -webkit-appearance: textfield; user-select: none; } INPUT, INPUT:hover, INPUT:focus, TEXTAREA, TEXTAREA:hover, TEXTAREA:focus, SELECT, SELECT:hover, SELECT:focus { border: none; background-color: transparent; outline: 0; box-shadow: none; padding: $input-padding-lg 0 0 0; line-height: calc(#{$input-line-height} + 1px); &.no-label { padding: $input-padding-sm 0px $input-padding-sm 0px; } } &.view > DIV:not(.addon) { font-size: 14px; padding: $input-padding-lg 0 0 0; &.no-label { padding-top:0px; } } &.create, &.edit, &.view { .addon, .addon.btn { display: table-cell; vertical-align: middle; width: 1%; white-space: nowrap; vertical-align: middle; color: #{$secondary}; } .addon { padding: 6px 12px; font-size: 14px; font-weight: normal; line-height: 1; text-align: center; border-left: solid thin #{$secondary}; } } &.suffix INPUT { padding-right: 8px; } .cron-label{ position: absolute; top: 100%; padding-top: 5px; left: 0; color: var(--input-label); } } ================================================ FILE: pkg/rancher-desktop/assets/styles/global/_resource.scss ================================================ .create-resource-container { .subtypes-container { display: flex; flex-wrap: wrap; width: 100%; } .subtype-content { width: 100%; } .subtype-banner { border-left: 5px solid var(--primary); border-radius: var(--border-radius); display: flex; flex-basis: 40%; margin: 10px; min-height: 80px; padding: 10px; box-shadow: 0 0 20px var(--shadow); &.disabled { cursor: not-allowed !important; background-color: var(--disabled-bg); } &.selected { background-color: var(--accent-btn); } &.top { background-image: linear-gradient( -90deg, var(--body-bg), var(--accent-btn) ); h2 { margin: 0px; } } .title { align-items: center; display: flex; width: 100%; h5 { margin: 0; } .flex-right { margin-left: auto; } } .description { color: var(--input-label); display: flex; flex-direction: column; justify-content: center; } &:not(.top) { align-items: top; flex-direction: row; justify-content: start; &:hover { cursor: pointer; box-shadow: 0px 0px 1px var(--outline-width) var(--outline); } } .round-image { border-radius: 50%; height: 50px; margin-right: 10px; width: 50px; overflow: hidden; } .banner-abbrv { align-items: center; background-color: var(--primary); color: white; display: flex; font-size: 2.5em; height: 100%; justify-content: center; width: 100%; } } } ================================================ FILE: pkg/rancher-desktop/assets/styles/global/_select.scss ================================================ .labeled-select { cursor: text; padding: 0; width: 100%; .selected { padding-top: $input-padding-lg; } } .col { > .labeled-select:not(.taggable), > .unlabeled-select:not(.taggable) { min-height: $input-height; padding-bottom: calc(#{$input-padding-sm}/2); } } .labeled-select, .unlabeled-select { min-width: 75px; // line-height: $input-line-height; .required { color: var(--error); } .v-select { &.inline { .vs__search { background-color: transparent; } .vs__dropdown-toggle, .vs__dropdown-toggle > * { background-color: transparent; border: transparent; } .vs__dropdown-menu { outline: none; } .selected { position: relative; top: 1.4em; } } } .v-select.inline.vs--single { &.vs--searching .vs__selected { display: none; } &:not(.vs--searching) { .vs__selected-options { overflow: hidden; flex-wrap: nowrap; .vs__selected { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; display: inline-block; } } } } .v-select.inline:not(.vs--single) { margin-bottom: -5px; // targets multi-select tag boxes to make the same size as rows next to it min-height: 30px; .vs__selected { min-height: 25px; padding: 0 7px; &:not(:only-child) { margin-bottom: 3px; } } } &.focused { outline: none; box-shadow: 0 0 0 var(--outline-width) var(--outline); .v-select { // Can toggle this to get full width dd - maybe make an option? .vs__dropdown-menu { min-width: max-content; background: var(--dropdown-bg); } } } } .unlabeled-select { background-color: var(--input-bg); border-radius: var(--border-radius); color: var(--input-text); padding: 3px 0; &.disabled { border: solid var(--border-width) var(--input-disabled-border); .vs__dropdown-toggle, input { cursor: not-allowed; } } .vs--single .vs__selected-options { flex-wrap: nowrap; } .v-select { &.inline { height: 100%; .vs__dropdown-toggle { height: 100%; } .vs__actions { width: auto; } } } &:not(.view) { background-color: var(--input-bg); border: solid var(--border-width) var(--input-border); &:hover { &, .vs__dropdown-menu { background: var(--input-hover-bg); } } &.disabled .v-select { background-color: var(--input-disabled-bg); border-color: var(--input-disabled-border); cursor: not-allowed; .vs__dropdown-toggle, input { cursor: not-allowed; } .vs__selected { color: var(--input-disabled-text); } } } .labeled-tooltip .status-icon { top: $input-padding-sm; } } ================================================ FILE: pkg/rancher-desktop/assets/styles/global/_table.scss ================================================ //bordered table .bordered-table { width: 100%; max-width: 100%; margin-bottom: 1rem; // background-color: var(--body-bg); border-collapse: collapse; th, td { padding: 12px; border-top: 1px solid var(--border); } thead th { vertical-align: bottom; border-bottom: 2px solid var(--border); } tbody + tbody { border-top: 2px solid var(--border); } table { // background-color: var(--body-bg); } } //zebra table .zebra-table { border-collapse: collapse; margin: 25px 0; min-width: 400px; border-radius: 5px 5px 0 0; overflow: hidden; box-shadow: 0 0 20px var(--shadow); thead { tr { background-color: var(--sortable-table-header-bg); color: #ffffff; text-align: left; } } th { padding: 12px 15px; font-weight: normal; border: 0; } td { padding: 12px 15px; border: 0; } tbody { tr { border-bottom: 1px solid var(--sortable-table-top-divider); &:nth-of-type(even) { background-color: var(--sortable-table-accent-bg); } &:last-of-type { border-bottom: 2px solid var(--sortable-table-top-divider); } } tr.active-row { color: var(--sortable-table-header-bg); } } } .for-inputs{ & TABLE.sortable-table { width: 100%; border-collapse: collapse; margin-bottom: $spacing; >TBODY>TR>TD, >THEAD>TR>TH { padding-right: $spacing; padding-bottom: $spacing; &:last-of-type { padding-right: 0; } } >TBODY>TR:first-of-type>TD { padding-top: $spacing; } >TBODY>TR:last-of-type>TD { padding-bottom: 0; } } &.edit, &.create, &.clone { TABLE.sortable-table>THEAD>TR>TH { border-color: transparent; } } } ================================================ FILE: pkg/rancher-desktop/assets/styles/global/_tooltip.scss ================================================ .v-popper__popper.v-popper--theme-tooltip { $triangle-size: 8px; $triangle-inner-size: $triangle-size - 1px; $center: calc(50% - #{$triangle-size}); display: block !important; z-index: z-index('tooltip'); max-width: 50vw; .v-popper__inner { background: var(--tooltip-bg); color: var(--tooltip-text); border-radius: var(--border-radius); padding: 8px; } .v-popper__arrow-container { border: 0 solid transparent; z-index: 1; position: absolute; width: $triangle-size; height: $triangle-size; .v-popper__arrow-outer { border-radius: 0; border: $triangle-size solid transparent; width: 0; height: 0; box-sizing: content-box; margin-left: -2px; } .v-popper__arrow-inner { border: $triangle-inner-size solid transparent; border-radius: 0; width: 0; height: 0; box-sizing: content-box; margin-left: -1px; } } :is(&[data-popper-placement^="top"], &[data-popper-placement^="bottom"]) { .v-popper__arrow-inner { margin-top: -$triangle-size; } } :is(&[data-popper-placement^="left"], &[data-popper-placement^="right"]) { .v-popper__arrow-inner { margin-top: calc(-2 * $triangle-size); } } &[data-popper-placement^="top"] { .v-popper__wrapper { margin-bottom: $triangle-size; } .v-popper__arrow-container > * { border-top-color: var(--tooltip-bg); border-bottom-width: 0; } } &[data-popper-placement^="bottom"] { .v-popper__inner { margin-top: $triangle-size; } .v-popper__arrow-container { top: 0; & > * { border-bottom-color: var(--tooltip-bg); border-top-width: 0; } } } &[data-popper-placement^="right"] { .v-popper__inner { margin-left: calc(#{$triangle-size} - 2px); } .v-popper__arrow-container > * { border-right-color: var(--tooltip-bg); border-left-width: 0; } } &[data-popper-placement^="left"] { .v-popper__inner { margin-right: $triangle-size; } .v-popper__arrow-container > * { border-left-color: var(--tooltip-bg); border-right-width: 0; } } &.tooltip-warning { .v-popper__inner { background: var(--tooltip-bg-warning); color: var(--tooltip-text-warning); } &[data-popper-placement^="top"] { .v-popper__arrow-container { .v-popper__arrow-outer { border-top-color: var(--tooltip-bg-warning); } } } &[data-popper-placement^="bottom"] { .v-popper__arrow-container { .v-popper__arrow-outer { border-bottom-color: var(--body-bg); } } } &[data-popper-placement^="right"] { .v-popper__arrow-container { .v-popper__arrow-outer { border-right-color: var(--tooltip-bg-warning); } } } &[data-popper-placement^="left"] { .v-popper__arrow-container { .v-popper__arrow-outer { border-left-color: var(--tooltip-bg-warning); } } } } } .v-popper__popper { $color: var(--popover-bg); top: 0; left: 0; border-radius: var(--border-radius-lg); &:focus { outline: none; } .v-popper__inner { background: $color; color: var(--popover-text); padding: 0; border-radius: var(--border-radius-lg); overflow: hidden; /* for border-radius */ border: 1px solid var(--dropdown-border); li { padding: 10px; &:not(.divider):hover { background-color: var(--dropdown-hover-bg); color: var(--dropdown-hover-text); cursor: pointer; } } a { color: var(--popover-text); } .resize-observer, .resize-observer object { position: absolute; } } .v-popper__arrow-container { border-color: transparent; .v-popper__arrow-outer { border-color: transparent; } } } .v-popper__popper.v-popper--theme-dropdown { z-index: z-index('tooltip'); &.containerLogsDropdown, &.fleet-summary-tooltip{ .v-popper__arrow-container { display: none; } } } .v-popper { display: inline; } .v-popper__popper.v-popper--theme-tooltip, .v-popper { &[aria-hidden='true'] { // This removes it from the layout of ButtonDropDown (so it doesn't render huge for SSR) but // still allows it to maintain its dimensions for v-tooltip to calculate the appropriate position. position: absolute; visibility: hidden; opacity: 0; transition: opacity .15s, visibility .15s; } &[aria-hidden='false'] { visibility: visible; opacity: 1; transition: opacity .15s; } } //icon tooltip .icon-info.v-popper--has-tooltip { font-size: 14px; } .tooltip-footer { top: 0; font-size: 12px; .v-popper__inner { font-size: 12px; } } ================================================ FILE: pkg/rancher-desktop/assets/styles/rancher-desktop.scss ================================================ /** Rancher Desktop specific styles */ body { background-color: var(--body-bg); color: var(--body-text); font-size: 14px; } .labeled-input { margin-bottom: 1em; } label > button:first-child:not(.btn-sm) { margin-right: 1em; } .locked-radio .radio-container span.radio-custom { &[aria-checked="true"] { opacity: 1; } &:not([aria-checked="true"]) { opacity: 1; background-color: var(--radio-locked-bg); box-shadow: var(--radio-locked-shadow); } } @media screen and (prefers-color-scheme: dark) { option { background-color: var(--input-bg); } } :is(.btn, button, [class*='btn-']):not([class*='role-']):not([class*=bg-primary]) { /* buttons should have one of the role- classes: * .role-primary, .role-secondary, .role-tertiary, .role-multi-action, etc. */ outline: 10px dashed red !important; } .btn-icon-text { display: flex; align-items: center; gap: 0.5rem; } :root { --preferences-content-padding: 0.75rem; } ================================================ FILE: pkg/rancher-desktop/assets/styles/themes/_dark.scss ================================================ :root { // Local variables for reused colors //dark sidebar $darkest: #141419; //dark body $darker: #1b1c21; //dark inputs $dark: #27292e; //dark borders and button $medium: #4a4b52; // dark disabled, $light: #6c6c76; //dark secondary $lighter: #b6b6c2; // dark main text $lightest: #ffffff; $secondary: $lighter; $disabled: $light; //Contrast colors $contrasted-dark: $lightest !default; $contrasted-light: $darkest !default; --default : #{$dark}; --default-text : #{$light}; --default-hover-bg : #{darken($dark, 10%)}; --default-hover-text : #{saturate($lightest, 20%)}; --default-active-bg : #{darken($dark, 25%)}; --default-active-text : #{contrast-color(darken($dark, 25%))}; --default-border : #($dark); --default-banner-bg : #{rgba($dark, 0.15)}; --default-light-bg : #{rgba($dark, 0.05)}; --muted : #{$disabled}; --body-bg : #{$darker}; --body-text : #{$lightest}; --scrollbar-thumb : #{$medium}; --scrollbar-thumb-dropdown : #{$medium}; --header-bg : #{$darker}; --header-border : #{$medium}; --header-btn-bg : #{$dark}; --header-btn-text : #{$lightest}; --footer-bg : #{$darker}; --footer-border : #{$medium}; --nav-bg : #{$darkest}; --nav-active : var(--primary-active-bg); --nav-border : #{$medium}; --nav-hover : var(--primary); --nav-expander-hover : var(--primary-banner-bg); --disabled-bg : #{darken($disabled, 10%)}; --disabled-text : #{$secondary}; --box-bg : #{$darkest}; --subtle-border : #{$darkest}; --border : #{$medium}; --topmenu-bg : #{$darkest}; --topmenu-text : #{$lightest}; --topmost-border : #{$medium}; --topmost-shadow : #{lighten($darkest, 5%)}; --topmost-light-hover : #{$medium}; --accent-btn : var(--primary-banner-bg); --accent-btn-hover : var(--primary); --accent-btn-hover-text : #{$lightest}; --modal-bg : #{$dark}; --modal-border : #{$medium}; --overlay-bg : #{rgba($darkest, 0.75)}; --shadow : #{rgba($darkest, 0.9)}; --checkbox-tick : #{$lightest}; --checkbox-border : #{$medium}; --checkbox-tick-disabled : #{lighten($disabled, 50%)}; --checkbox-disabled-bg : #{$disabled}; --checkbox-tick-locked : #{$darkest}; --checkbox-locked-bg : #{lighten($disabled, 50%)}; --checkbox-ticked-bg : var(--primary); --checkbox-locked-border : #{lighten($disabled, 50%)}; --checkbox-locked-shadow : #{lighten($disabled, 50%)}; --dropdown-bg : #{mix($medium, $dark, 10%)}; --dropdown-border : #{$light}; --dropdown-divider : #{$light}; --dropdown-text : #{$link}; --dropdown-active-text : #{$lightest}; --dropdown-active-bg : #{$selected}; --dropdown-hover-text : #{$lightest}; --dropdown-hover-bg : #{$link}; --dropdown-disabled-bg : #{$disabled}; --dropdown-disabled-text : #{$disabled}; --dropdown-locked-text : #{$lightest}; --input-text : #{$lightest}; --input-label : #{$lighter}; --input-placeholder : #{$disabled}; --input-border : var(--border); --input-bg : var(--body-bg); --input-bg-accent : #{darken($dark, 3%)}; --input-hover-bg : var(--box-bg); --input-focus-bg : var(--box-bg); --input-disabled-text : #{darken($lightest, 50%)}; --input-disabled-label : #{darken($lighter, 30%)}; --input-disabled-bg : #{darken($disabled, 30%)}; --input-disabled-border : #{darken($medium, 30%)}; --input-disabled-placeholder : #{darken($disabled, 10%)}; --input-addon-bg : #{$darker}; --input-locked-text : #{$lightest}; --radio-locked-bg : var(--body-bg); --radio-locked-shadow : var(--body-bg); --progress-bg : #{$medium}; --progress-divider : #{$lightest}; --sortable-table-bg : #{lighten($darkest, 10%)}; --sortable-table-row-bg : #{$darker};; --sortable-table-header-bg : #{$darkest}; --sortable-table-accent-bg : #{$darker}; --sortable-table-accent-alt : #{$dark}; --sortable-table-top-divider : var(--border); --sortable-table-hover-bg : #{$darkest}; --sortable-table-selected-bg : var(--primary-light-bg); --sortable-table-group-label : #{$lighter}; --tag-primary : #{$lightest}; --tag-bg : #{$medium}; --popover-bg : var(--body-bg); --popover-border : var(--border); --popover-text : var(--body-text); --tooltip-bg : #{$medium}; --tooltip-border : var(--tag-primary); --tooltip-text : var(--body-text); --tooltip-text-warning : var(--body-text); --icon-circle : #{$medium}; --tabbed-border : #{$medium}; --tabbed-sidebar-bg : #{$darkest}; --tabbed-container-bg : #{mix($medium, $dark, 20%)}; --yaml-editor-bg : #{$darkest}; --diff-border : var(--border); --diff-header-bg : var(--nav-bg); --diff-header-border : var(--border); --diff-header : #{rgba($darkest, 0.3)}; --diff-linenum-bg : var(--nav-bg); --diff-linenum : var(--muted); --diff-linenum-border : var(--border); --diff-line-ins-bg : $success; --diff-line-del-bg : #{rgba($error, 0.75)}; --diff-del-bg : #{rgba($error, 0.3)}; --diff-del-border : #{$error}; --diff-ins-bg : #{rgba($success, 0.3)}; --diff-ins-border : #{rgba($success, 0.5)}; --diff-chg-ins : #{rgba($success, 0.25)}; --diff-chg-del : #{rgba($warning, 0.5)}; --diff-empty-placeholder : #{$darker}; --wm-tabs-bg : #{mix($medium, $dark, 10%)}; --wm-tab-bg : #{$darkest}; --wm-closer-hover-bg : #{$medium}; --wm-tab-active-bg : #{$darker}; --wm-title-bg : #{$darkest}; --wm-title-border : #{$medium}; --wm-body-bg : #{$darkest}; --wm-border : black; --glance-divider : #{$medium}; --resource-gauge-back-circle : 74, 75, 82, 0.5; --simple-box-bg : #{$darker}; --simple-box-border : #{$darkest}; --simple-box-divider : #{$medium}; --simple-box-shadow : rgba(0, 0, 0, 0.15); --terminal-bg : var(--wm-body-bg); --terminal-cursor : var(--warning); --terminal-selection : #{$selected}; --terminal-text : var(--body-text); --logs-bg : var(--wm-body-bg); --logs-highlight : var(--wm-body-bg); --logs-highlight-bg : var(--warning); --logs-text : var(--body-text); --gauge-divider : rgba(255, 255, 255, 0.3); --gauge-success-primary : 75, 95, 64; --gauge-success-secondary : 150, 189, 127; --gauge-warning-primary : 218, 195, 66; --gauge-warning-secondary : 109, 98, 33; --gauge-error-primary : 239, 90, 83; --gauge-error-secondary : 120, 45, 42; --product-icon : #{$lighter}; --product-icon-active : #{$lightest}; --button-icon : #{$medium}; --button-icon-bg : #{$lightest}; } ================================================ FILE: pkg/rancher-desktop/assets/styles/themes/_light.scss ================================================ // Local variables for reused colors //light main text $darkest : #141419; //light secondary $darker : #6C6C76; //light disabled $dark : #B6B6C2; //light border and buttons $medium : #DCDEE7; //light inputs $light : #EEEFF4; //light sidebar and box $lighter : #F4F5FA; //light body bg $lightest : #FFFFFF; //color for items that are not enabled $disabled : $medium; $primary : #3D98D3; $secondary : $darker; $link : #3D98D3; // Status colors $success : #5D995D; $warning : #DAC342; $error : #F64747; $info : #3D98D3; $contrasted-dark: $darkest !default; $contrasted-light: $lightest !default; // Text selection color for terminal window (we don't want this to change with the primary color) // The terminal alway uses a light background, so okay to use a fixed color $selected: rgba(#3D98D3, .5); :root { --primary : #{$primary}; --primary-text : #{contrast-color($primary)}; --primary-hover-bg : #{darken($primary, 10%)}; --primary-hover-text : #{saturate($lightest, 20%)}; --primary-active-bg : #{darken($primary, 25%)}; --primary-active-text : #{contrast-color(darken($primary, 25%))}; --primary-border : #($primary); --primary-banner-bg : #{rgba($primary, 0.15)}; --primary-light-bg : #{rgba($primary, 0.05)}; .text-primary { color: var(--primary) !important; } .bg-primary { background-color: var(--primary); color: var(--primary-text); &.btn:hover { color: var(--primary-hover-text); background: var(--primary-hover-bg); transition: all 0.3s ease; } &.btn:active { color: var(--primary-active-text); background: var(--primary-active-bg); } } --link : #{$link}; --link-text : #{contrast-color($link)}; --link-hover-bg : #{darken($link, 10%)}; --link-hover-text : #{saturate($lightest, 20%)}; --link-active-bg : #{darken($link, 25%)}; --link-active-text : #{contrast-color(darken($link, 25%))}; --link-border : #($link); --link-banner-bg : #{rgba($link, 0.15)}; --link-light-bg : #{rgba($link, 0.05)}; .text-link { color: var(--link) !important; } .bg-link { background-color: var(--link); color: var(--link-text); &.btn:hover { color: var(--link-hover-text); background: var(--link-hover-bg); transition: all 0.3s ease; } &.btn:active { color: var(--link-active-text); background: var(--link-active-bg); } } --default : #{$light}; --default-text : #{contrast-color($light)}; --default-hover-bg : #{darken($light, 10%)}; --default-hover-text : #{saturate($lightest, 20%)}; --default-active-bg : #{darken($light, 25%)}; --default-active-text : #{contrast-color(darken($light, 25%))}; --default-border : #($light); --default-banner-bg : #{rgba($light, 0.15)}; --default-light-bg : #{rgba($light, 0.05)}; .text-default { color: var(--default) !important; } .bg-default { background-color: var(--default); color: var(--default-text); &.btn:hover { color: var(--default-hover-text); background: var(--default-hover-bg); transition: all 0.3s ease; } &.btn:active { color: var(--default-active-text); background: var(--default-active-bg); } } --muted : #{$dark}; .text-muted { color: var(--muted) !important; } --darker : #{$darker}; --darker-text : #{contrast-color($darker)}; --darker-hover-bg : #{darken($darker, 10%)}; --darker-hover-text : #{saturate($lightest, 20%)}; --darker-active-bg : #{darken($darker, 25%)}; --darker-active-text : #{contrast-color(darken($darker, 25%))}; --darker-border : #($darker); --darker-banner-bg : #{rgba($darker, 0.15)}; --darker-light-bg : #{rgba($darker, 0.05)}; .text-darker { color: var(--default) !important; } .bg-darker { background-color: var(--darker); color: var(--darker-text); &.btn:hover { color: var(--darker-hover-text); background: var(--darker-hover-bg); transition: all 0.3s ease; } &.btn:active { color: var(--darker-active-text); background: var(--darker-active-bg); } } --success : #{$success}; --success-text : #{contrast-color($success)}; --success-hover-bg : #{darken($success, 10%)}; --success-hover-text : #{saturate($lightest, 20%)}; --success-active-bg : #{darken($success, 25%)}; --success-active-text : #{contrast-color(darken($success, 25%))}; --success-border : #($success); --success-banner-bg : #{rgba($success, 0.15)}; --success-light-bg : #{rgba($success, 0.05)}; .text-success { color: var(--success) !important; } .bg-success { background-color: var(--success); color: var(--success-text); &.btn:hover { color: var(--success-hover-text); background: var(--success-hover-bg); transition: all 0.3s ease; } &.btn:active { color: var(--success-active-text); background: var(--success-active-bg); } } --info : #{$info}; --info-text : #{contrast-color($info)}; --info-hover-bg : #{darken($info, 10%)}; --info-hover-text : #{saturate($lightest, 20%)}; --info-active-bg : #{darken($info, 25%)}; --info-active-text : #{contrast-color(darken($info, 25%))}; --info-border : #($info); --info-banner-bg : #{rgba($info, 0.15)}; --info-light-bg : #{rgba($info, 0.05)}; .text-info { color: var(--info) !important; } .bg-info { background-color: var(--info); color: var(--info-text); &.btn:hover { color: var(--info-hover-text); background: var(--info-hover-bg); transition: all 0.3s ease; } &.btn:active { color: var(--info-active-text); background: var(--info-active-bg); } } --warning : #{$warning}; --warning-text : #{contrast-color($warning)}; --warning-hover-bg : #{darken($warning, 10%)}; --warning-hover-text : #{saturate($lightest, 20%)}; --warning-active-bg : #{darken($warning, 25%)}; --warning-active-text : #{contrast-color(darken($warning, 25%))}; --warning-border : #($warning); --warning-banner-bg : #{rgba($warning, 0.15)}; --warning-light-bg : #{rgba($warning, 0.05)}; .text-warning { color: var(--error) !important; } .bg-warning { background-color: var(--warning); color: var(--warning-text); &.btn:hover { color: var(--warning-hover-text); background: var(--warning-hover-bg); transition: all 0.3s ease; } &.btn:active { color: var(--warning-active-text); background: var(--warning-active-bg); } } --error : #{$error}; --error-text : #{contrast-color($error)}; --error-hover-bg : #{darken($error, 10%)}; --error-hover-text : #{saturate($lightest, 20%)}; --error-active-bg : #{darken($error, 25%)}; --error-active-text : #{contrast-color(darken($error, 25%))}; --error-border : #($error); --error-banner-bg : #{rgba($error, 0.15)}; --error-light-bg : #{rgba($error, 0.05)}; .text-error { color: var(--error) !important; } .bg-error { background-color: var(--error); color: var(--error-text); &.btn:hover { color: var(--error-hover-text); background: var(--error-hover-bg); transition: all 0.3s ease; } &.btn:active { color: var(--error-active-text); background: var(--error-active-bg); } } --body-bg : #{$lightest}; --body-text : #{$darkest}; --scrollbar-thumb : #{$dark}; --scrollbar-thumb-dropdown : #{$lighter}; --scrollbar-track : transparent; --header-bg : #{$lightest}; --header-btn-bg : #{$light}; --header-btn-text : #{$darker}; --header-input-text : #{$lightest}; --header-height : 55px; --header-border : #{$medium}; --header-border-size : 1px; --nav-width : 230px; --nav-bg : #{$lightest}; --nav-active : #{$light}; --nav-hover : #{$medium}; --nav-expander-hover : #{darken($medium, 10%)}; --nav-border : #{$medium}; --nav-border-size : 1px; --footer-bg : #{$lightest}; --footer-border : #{$medium}; --topmenu-bg : #{$lightest}; --topmenu-text : #{$darkest}; --topmost-border : #{$medium}; --topmost-shadow : #{lighten($lightest, 10%)}; --topmost-light-hover : #{$light}; --disabled-bg : #{$disabled}; --disabled-text : #{$secondary}; --box-bg : #{$lighter}; --subtle-border : #{$medium}; --border : #{$medium}; --border-width : 1px; --border-radius : 4px; --border-radius-md : 6px; --border-radius-lg : 8px; --outline : var(--primary); --outline-width : 1px; --accent-btn : var(--primary-banner-bg); --accent-btn-hover : var(--primary); --accent-btn-hover-text : #{$lightest}; --modal-bg : #{$lightest}; --modal-border : #{$dark}; --overlay-bg : #{rgba($lighter, 0.75)}; --shadow : #{rgba($medium, 0.85)}; --checkbox-tick : #{$lightest}; --checkbox-border : #{$medium}; --checkbox-tick-disabled : #{darken($disabled, 40%)}; --checkbox-disabled-bg : #{$disabled}; --checkbox-tick-locked : #{$darkest}; --checkbox-locked-bg : #{lighten($disabled, 5%)}; --checkbox-ticked-bg : #{$link}; --checkbox-locked-border : #{lighten($disabled, 5%)}; --checkbox-locked-shadow : #{lighten($disabled, 5%)}; --dropdown-bg : #{$lightest}; --dropdown-border : #{$medium}; --dropdown-divider : #{$medium}; --dropdown-text : #{$link}; --dropdown-active-text : #{$lightest}; --dropdown-active-bg : #{$dark}; --dropdown-hover-text : var(--body-text); --dropdown-hover-bg : #{$light}; --dropdown-disabled-text : var(--muted); --dropdown-disabled-bg : #{$disabled}; --dropdown-locked-text : #{$darkest}; // UNUSED? --card-header : var(--primary-banner-bg); --input-text : #{$darkest}; --input-label : #{$secondary}; --input-placeholder : #{darken($disabled, 10%)}; --input-border : var(--border); --input-bg : var(--body-bg); --input-bg-accent : #{darken($light, 2%)}; --input-hover-bg : var(--box-bg); --input-focus-bg : var(--box-bg); --input-disabled-text : #{darken($disabled, 60%)}; --input-disabled-label : #{darken($disabled, 40%)}; --input-disabled-bg : #{$disabled}; --input-disabled-border : #{darken($medium, 10%)}; --input-disabled-placeholder : #{darken($medium, 15%)}; --input-addon-bg : #{$darker}; --input-locked-text : #{$darkest}; --radio-locked-bg : var(--body-bg); --radio-locked-shadow : var(--body-bg); --progress-bg : #{$medium}; --progress-divider : #{$medium}; --sortable-table-bg : #{darken($lightest, 5%)}; --sortable-table-row-bg : #{$lightest}; --sortable-table-header-bg : #{$lighter}; --sortable-table-accent-bg : #{$lighter}; --sortable-table-accent-alt : #{$lightest}; --sortable-table-top-divider : var(--border); --sortable-table-body-divider : #{$medium}; --sortable-table-hover-bg : #{$lighter}; //--sortable-table-selected-bg : #{rgba($primary, 0.02)}; --sortable-table-selected-bg : var(--primary-light-bg); --sortable-table-group-label : #{$secondary}; --tag-primary : #{$darkest}; --tag-bg : #{$medium}; --popover-bg : var(--body-bg); --popover-border : var(--border); --popover-text : var(--body-text); --popover-border-radius : var(--border-radius); --tooltip-bg : #{$medium}; --tooltip-border : var(--tag-primary); --tooltip-text : var(--tag-primary); --tooltip-bg-warning : #{rgba($warning, 0.8)}; --tooltip-text-warning : var(--body-text); --icon-circle : #{$medium}; --tabbed-border : #{$medium}; --tabbed-sidebar-bg : #{$lighter}; --tabbed-container-bg : #{mix($light, $lighter, 15%)}; --yaml-editor-bg : #{$lighter}; --diff-border : var(--border); --diff-header-bg : var(--nav-bg); --diff-header-border : var(--border); --diff-header : #{rgba($darkest, 0.3)}; --diff-linenum-bg : var(--nav-bg); --diff-linenum : var(--muted); --diff-linenum-border : var(--border); --diff-line-ins-bg : $success; --diff-line-del-bg : #{rgba($error, 0.75)}; --diff-del-bg : #{rgba($error, 0.3)}; --diff-del-border : #{$error}; --diff-ins-bg : #{rgba($success, 0.3)}; --diff-ins-border : #{rgba($success, 0.5)}; --diff-chg-ins : #{rgba($success, 0.25)}; --diff-chg-del : #{rgba($warning, 0.5)}; --diff-empty-placeholder : #{$lightest}; --wm-tabs-bg : #{$medium}; --wm-tab-bg : #{$light}; --wm-closer-hover-bg : #{$lighter}; --wm-tab-active-bg : #{$lighter}; --wm-title-bg : #{$lightest}; --wm-title-border : #{$medium}; --wm-body-bg : #{$lighter}; --wm-border : var(--border); --wm-tab-height : 29px; --glance-bg-rgb : 61, 152, 211; --glance-divider : #{$medium}; --resource-gauge-back-circle : 255, 255, 255, 0.15; --simple-box-bg : #{$lightest}; --simple-box-border : #{$medium}; --simple-box-divider : #{$medium}; --simple-box-shadow : none; --terminal-bg : var(--body-bg); --terminal-cursor : var(--warning); --terminal-selection : #{$selected}; --terminal-text : var(--body-text); --logs-bg : var(--wm-body-bg); --logs-highlight : var(--wm-body-bg); --logs-highlight-bg : var(--warning); --logs-text : var(--body-text); --gauge-divider : #{$lightest}; --gauge-zero : #{$medium}; --gauge-success-primary : 150, 189, 127; --gauge-success-secondary : 190, 211, 172; --gauge-warning-primary : 238, 226, 176; --gauge-warning-secondary : 218, 195, 66; --gauge-error-primary : 249, 186, 171; --gauge-error-secondary : 239, 90, 83; --sizzle-0 : 180, 210, 30; --sizzle-1 : 225, 45, 74; --sizzle-2 : 212, 66, 148; --sizzle-3 : 0, 169, 217; --sizzle-4 : 244, 136, 68; --sizzle-5 : 0, 147, 128; --sizzle-6 : 136, 81, 165; --sizzle-7 : 45, 47, 149; --sizzle-8 : 255, 235, 0; --sizzle-success : #{red($success)}, #{green($success)}, #{blue($success)}; --sizzle-info : #{red($info)}, #{green($info)}, #{blue($info)}; --sizzle-warning : #{red($warning)}, #{green($warning)}, #{blue($warning)}; --sizzle-error : #{red($error)}, #{green($error)}, #{blue($error)}; --sizzle-unknown : #{red($disabled)},#{green($disabled)},#{blue($disabled)}; $rancher : $primary; $partner : #FEA424; $other : #614EA2; --app-rancher-accent : #{$rancher}; --app-rancher-accent-text : #{$darkest}; --app-partner-accent : #{$partner}; --app-partner-accent-text : black; --app-color1-accent : rgba(var(--sizzle-1), 1); --app-color1-accent-text : white; --app-color2-accent : rgba(var(--sizzle-2), 1); --app-color2-accent-text : white; --app-color3-accent : rgba(var(--sizzle-3), 1); --app-color3-accent-text : white; --app-color4-accent : rgba(var(--sizzle-4), 1); --app-color4-accent-text : white; --app-color5-accent : rgba(var(--sizzle-5), 1); --app-color5-accent-text : white; --app-color6-accent : rgba(var(--sizzle-6), 1); --app-color6-accent-text : white; --app-color7-accent : rgba(var(--sizzle-7), 1); --app-color7-accent-text : white; --app-color8-accent : rgba(var(--sizzle-8), 1); --app-color8-accent-text : white; --product-icon : #{$darker}; --product-icon-active : #{$darkest}; --button-icon : #{$dark}; --button-icon-bg : #{$secondary}; } ================================================ FILE: pkg/rancher-desktop/assets/styles/themes/_suse.scss ================================================ .suse { $primary: hsl(151, 59%, 46%); $info: mix($primary, $secondary, 50%); $selected: rgba($primary, .5); --primary : #{$primary}; --primary-text : #{contrast-color($primary)}; --primary-hover-bg : #{darken($primary, 10%)}; --primary-hover-text : #{saturate($lightest, 20%)}; --primary-active-bg : #{darken($primary, 25%)}; --primary-active-text : #{contrast-color(darken($primary, 25%))}; --primary-border : #($primary); --primary-banner-bg : #{rgba($primary, 0.15)}; --primary-light-bg : #{rgba($primary, 0.05)}; --info : #{$info}; --info-text : #{contrast-color($info)}; --info-hover-bg : #{darken($info, 10%)}; --info-hover-text : #{saturate($lightest, 20%)}; --info-active-bg : #{darken($info, 25%)}; --info-active-text : #{contrast-color(darken($info, 25%))}; --info-border : #($info); --info-banner-bg : #{rgba($info, 0.15)}; --info-light-bg : #{rgba($info, 0.05)}; } ================================================ FILE: pkg/rancher-desktop/assets/styles/vendor/normalize.scss ================================================ /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ /* Document ========================================================================== */ /** * 1. Correct the line height in all browsers. * 2. Prevent adjustments of font size after orientation changes in iOS. */ html { line-height: 1.15; /* 1 */ -webkit-text-size-adjust: 100%; /* 2 */ } /* Sections ========================================================================== */ /** * Remove the margin in all browsers. */ body { margin: 0; } /** * Render the `main` element consistently in IE. */ main { display: block; } /** * Correct the font size and margin on `h1` elements within `section` and * `article` contexts in Chrome, Firefox, and Safari. */ h1 { font-size: 2em; margin: 0.67em 0; } /* Grouping content ========================================================================== */ /** * 1. Add the correct box sizing in Firefox. * 2. Show the overflow in Edge and IE. */ hr { box-sizing: content-box; /* 1 */ height: 0; /* 1 */ overflow: visible; /* 2 */ } /** * 1. Correct the inheritance and scaling of font size in all browsers. * 2. Correct the odd `em` font sizing in all browsers. */ pre { font-family: monospace, monospace; /* 1 */ font-size: 1em; /* 2 */ } /* Text-level semantics ========================================================================== */ /** * Remove the gray background on active links in IE 10. */ a { background-color: transparent; } /** * 1. Remove the bottom border in Chrome 57- * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. */ abbr[title] { border-bottom: none; /* 1 */ text-decoration: underline; /* 2 */ text-decoration: underline dotted; /* 2 */ } /** * Add the correct font weight in Chrome, Edge, and Safari. */ b, strong { font-weight: bolder; } /** * 1. Correct the inheritance and scaling of font size in all browsers. * 2. Correct the odd `em` font sizing in all browsers. */ code, kbd, samp { font-family: monospace, monospace; /* 1 */ font-size: 1em; /* 2 */ } /** * Add the correct font size in all browsers. */ small { font-size: 80%; } /** * Prevent `sub` and `sup` elements from affecting the line height in * all browsers. */ sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } /* Embedded content ========================================================================== */ /** * Remove the border on images inside links in IE 10. */ img { border-style: none; } /* Forms ========================================================================== */ /** * 1. Change the font styles in all browsers. * 2. Remove the margin in Firefox and Safari. */ button, input, optgroup, select, textarea { font-family: inherit; /* 1 */ font-size: 100%; /* 1 */ line-height: 1.15; /* 1 */ margin: 0; /* 2 */ } /** * Show the overflow in IE. * 1. Show the overflow in Edge. */ button, input { /* 1 */ overflow: visible; } /** * Remove the inheritance of text transform in Edge, Firefox, and IE. * 1. Remove the inheritance of text transform in Firefox. */ button, select { /* 1 */ text-transform: none; } /** * Correct the inability to style clickable types in iOS and Safari. */ button, [type="button"], [type="reset"], [type="submit"] { -webkit-appearance: button; } /** * Remove the inner border and padding in Firefox. */ button::-moz-focus-inner, [type="button"]::-moz-focus-inner, [type="reset"]::-moz-focus-inner, [type="submit"]::-moz-focus-inner { border-style: none; padding: 0; } /** * Restore the focus styles unset by the previous rule. */ button:-moz-focusring, [type="button"]:-moz-focusring, [type="reset"]:-moz-focusring, [type="submit"]:-moz-focusring { outline: 1px dotted ButtonText; } /** * Correct the padding in Firefox. */ fieldset { padding: 0.35em 0.75em 0.625em; } /** * 1. Correct the text wrapping in Edge and IE. * 2. Correct the color inheritance from `fieldset` elements in IE. * 3. Remove the padding so developers are not caught out when they zero out * `fieldset` elements in all browsers. */ legend { box-sizing: border-box; /* 1 */ color: inherit; /* 2 */ display: table; /* 1 */ max-width: 100%; /* 1 */ padding: 0; /* 3 */ white-space: normal; /* 1 */ } /** * Add the correct vertical alignment in Chrome, Firefox, and Opera. */ progress { vertical-align: baseline; } /** * Remove the default vertical scrollbar in IE 10+. */ textarea { overflow: auto; } /** * 1. Add the correct box sizing in IE 10. * 2. Remove the padding in IE 10. */ [type="checkbox"], [type="radio"] { box-sizing: border-box; /* 1 */ padding: 0; /* 2 */ } /** * Correct the cursor style of increment and decrement buttons in Chrome. */ [type="number"]::-webkit-inner-spin-button, [type="number"]::-webkit-outer-spin-button { height: auto; } /** * 1. Correct the odd appearance in Chrome and Safari. * 2. Correct the outline style in Safari. */ [type="search"] { -webkit-appearance: textfield; /* 1 */ outline-offset: -2px; /* 2 */ } /** * Remove the inner padding in Chrome and Safari on macOS. */ [type="search"]::-webkit-search-decoration { -webkit-appearance: none; } /** * 1. Correct the inability to style clickable types in iOS and Safari. * 2. Change font properties to `inherit` in Safari. */ ::-webkit-file-upload-button { -webkit-appearance: button; /* 1 */ font: inherit; /* 2 */ } /* Interactive ========================================================================== */ /* * Add the correct display in Edge, IE 10+, and Firefox. */ details { display: block; } /* * Add the correct display in all browsers. */ summary { display: list-item; } /* Misc ========================================================================== */ /** * Add the correct display in IE 10+. */ template { display: none; } /** * Add the correct display in IE 10. */ [hidden] { display: none; } ================================================ FILE: pkg/rancher-desktop/assets/styles/vendor/vue-select.scss ================================================ .v-select { position: relative; font-family: inherit; &.auto-width { display: inline-block; width: auto; min-width: 2em; } } .v-select, .v-select * { box-sizing: border-box; } .vs__dropdown-toggle { cursor: pointer; } .vs--disabled { .vs__dropdown-toggle, .vs__clear, .vs__search, .vs__open-indicator { cursor: not-allowed; color: var(--dropdown-disabled-text); } } .vs__dropdown-menu { display: block; position: absolute; left: -2px; z-index: z-index('dropdownContent'); padding: $input-padding-sm 0; margin: 0; width: calc(100% + 4px); max-height: 350px; min-width: 160px; overflow-y: auto; border: 1px solid var(--dropdown-border); border-radius: var(--border-radius); text-align: left; list-style: none; background: var(--dropdown-bg); &::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb-dropdown) !important; border-radius: 4px; } &[data-popper-placement='top'] { border-radius: 4px 4px 0 0; border-top-style: solid; box-shadow: 0px -8px 16px 0px var(--shadow); } } .vs__dropdown-option { line-height: 1.42857143; /* Normalize line height */ display: block; padding: 0 calc(#{$input-padding-sm}/2); clear: both; color: var(--dropdown-text); white-space: nowrap; z-index: 1000; &:hover { cursor: pointer; } a { display: block; &:hover { color: var(--body-text); } } &.vs__dropdown-option--disabled { color: var(--dropdown-disabled-text); cursor: not-allowed; hr { cursor: default; } } &.vs__dropdown-option--selected { background-color: var(--dropdown-active-bg); color: var(--dropdown-active-text); text-decoration: none; } &.vs__dropdown-option--highlight { color: var(--dropdown-hover-text); background: var(--dropdown-hover-bg); a { color: var(--dropdown-hover-text); text-decoration: none; } } } .vs__dropdown-toggle { appearance: none; display: flex; background: var(--input-bg); border: 1px solid var(--dropdown-border); border-radius: var(--border-radius); white-space: normal; } .vs__selected-options { display: flex; flex-basis: 100%; flex-grow: 1; flex-wrap: wrap; padding: 0; position: relative; } .lg .vs__selected-options { margin: 8px; } .vs__actions { display: flex; align-items: center; pointer-events: none; position: relative; width: 100%; justify-content: flex-end; flex-shrink: 8; svg { display: none; } &:after { content: $icon-chevron-down; font-family: 'icons'; font-size: 2rem; color: var(--secondary); } } .vs--searchable .vs__dropdown-toggle { cursor: text; } .vs--unsearchable .vs__dropdown-toggle { cursor: pointer; } $transition-timing-function: cubic-bezier(1, -0.115, 0.975, 0.855); $transition-duration: 150ms; .vs__action:after { fill: var(--dropdown-disabled-text); transform: scale(1); transition: transform $transition-duration $transition-timing-function; transition-timing-function: $transition-timing-function; } .vs--open .vs__actions:after { transform: rotate(180deg) scale(1); } .vs--loading .vs__open-indicator { opacity: 0; } /** * Super weird bug... If this declaration is grouped * below, the cancel button will still appear in chrome. * If it's up here on it's own, it'll hide it. */ .vs__search::-webkit-search-cancel-button { display: none; } .vs__search::-webkit-search-decoration, .vs__search::-webkit-search-results-button, .vs__search::-webkit-search-results-decoration, .vs__search::-ms-clear { display: none; } .vs__search, .vs__search:focus { appearance: none; border-left: none; outline: none; margin: 0; background: none; box-shadow: none; width: 0; max-width: 100%; flex-grow: 1; margin-left: $input-padding-sm; } .vs__search::placeholder { color: var(--input-placeholder); } .vs--unsearchable { .vs__search { opacity: 1; &:hover { cursor: pointer; } } } .vs--single.vs--searching:not(.vs--open):not(.vs--loading) { .vs__search { opacity: 0.2; } } /* States */ .vs--single { .vs__selected { background-color: transparent; border-color: transparent; } &.vs--searching .vs__selected { display: none; } } .vs__selected { display: flex; align-items: center; background-color: var(--accent-btn); border: 1px solid var(--primary); border-radius: 3px; color: var(--link); margin-left: $input-padding-sm; &:not(:last-of-type) { margin-right: 2px; } } .vs__deselect { display: inline-flex; appearance: none; margin-left: 8px; padding: 0; border: 0; cursor: pointer; background: none; fill: var(--primary); svg { display: none; } &:after { content: $icon-close; font-family: 'icons'; color: var(--link); } } /*inline single-option select*/ .v-select.inline { background-color: transparent; &.vs--single { min-height: 29px; &.vs--open { .vs__selected { position: absolute; opacity: 0.4; } .vs__search { margin-left: $input-padding-sm; } } .vs__selected { color: var(--input-text); } } .vs__dropdown-menu { min-width: 0px; margin-top: 2px; } .vs__dropdown-toggle { background-color: var(--input-bg); border: none; padding: none; border-radius: var(--border-radius); border: 1px solid var(--dropdown-border); } &.vs--single .vs__selected-options { align-items: center; } .vs__search { background-color: rgba(0, 0, 0, 0); &:hover { background-color: rgba(0, 0, 0, 0); } } .vs__open-indicator { fill: var(--input-label); } .vs__clear { display: none; } } .v-select.mini { position: relative; top: 2px; .vs__dropdown-toggle { padding: 5px 0; } .vs__selected { margin: 0; } input { padding: 0; } } .vs__selected-options input { width: 0; display: inline-block; border: 0; background-color: var(--input-bg); color: var(--input-text); } header .vs__selected-options input { color: var(--header-input-text); } header .vs-select .vs__dropdown-toggle { background: var(--error) !important; } .vs__no-options { color: var(--dropdown-text); padding: 3px 20px; } header { .unlabeled-select { padding: 0; &.focused { border: 0; } } } ================================================ FILE: pkg/rancher-desktop/assets/translations/en-us.yaml ================================================ ############################## # Special stuff ############################## generic: add: Add back: Back cancel: Cancel close: Close comingSoon: Coming Soon copy: Copy create: Create created: Created customize: Customize default: Default disabled: Disabled done: Done enabled: Enabled ignored: Ignored invalidCron: Invalid cron schedule labelsAndAnnotations: Labels & Annotations loading: Loading… members: Members na: n/a name: Name never: Never none: None notFound: Not Found number: '{prefix}{value, number}{suffix}' overview: Overview plusMore: "+ {n} more" readFromFile: Read from File register: Register remove: Remove resource: |- {count, plural, one {resource} other {resources} } resourceCount: |- {count, plural, one {1 resource} other {# resources} } save: Save showAdvanced: Show Advanced hideAdvanced: Hide Advanced type: Type unknown: Unknown key: Key value: Value yes: Yes no: No units: time: 5s: 5s 10s: 10s 30s: 30s 1m: 1m 5m: 5m 15m: 15m 30m: 30m 1h: 1h 2h: 2h 6h: 6h 1d: 1d 7d: 7d 30d: 30d locale: en-us: English zh-hans: 简体中文 none: (None) nav: backToRancher: Cluster Manager clusterTools: Cluster Tools kubeconfig: Download KubeConfig import: Import YAML home: Home shell: Kubectl Shell support: Get Support group: cluster: Cluster inUse: More Resources rbac: RBAC serviceDiscovery: Service Discovery starred: Starred storage: Storage workload: Workload monitoring: Monitoring ns: all: All Namespaces clusterLevel: Only Cluster Resources namespace: "{name}" namespaced: Only Namespaced Resources orphan: Not in a Project project: "Project: {name}" system: Only System Namespaces user: Only User Namespaces apps: Apps categories: explore: Explore Cluster multiCluster: Global Apps legacy: Legacy Apps configuration: Configuration search: placeholder: Type to search clusters noResults: No matching clusters resourceSearch: label: Resource Search toolTip: Resource Search {key} placeholder: Type to search for a resource... header: setLoginPage: Set as login page restoreCards: Restore hidden cards userMenu: clusterDashboard: Cluster Dashboard preferences: Preferences accountAndKeys: Account & API Keys logOut: Log Out product: apps: Apps & Marketplace auth: Users & Authentication backup: Rancher Backups cis: CIS Benchmark ecm: Cluster Manager explorer: Cluster Explorer fleet: Continuous Delivery longhorn: Longhorn manager: Cluster Management gatekeeper: OPA Gatekeeper istio: Istio logging: Logging rio: Rio settings: Global Settings clusterManagement: Cluster Management monitoring: Monitoring mcapps: Multi-cluster Apps version: Version versionChecking: (checking...) networkStatus: Network status kubernetesVersion: Kubernetes containerEngine: fullName: Container Engine abbreviation: CE notFound: not found deactivated: deactivated suffix: percent: "%" milliCpus: milli CPUs cpus: CPUs ib: iB mib: MiB gb: GB revisions: |- {count, plural, =1 { Revision } other { Revisions } } seconds: |- {count, plural, =1 { Second } other { Seconds } } sec: Sec times: |- {count, plural, =1 { Time } other { Times } } ############################## # Components & Pages ############################## app: name: Rancher Desktop update: "There's one last step to finish updating Rancher Desktop" firstRun: kubernetesVersion: legend: Kubernetes Version cachedOnly: (cached versions only) ok: OK marketplace: title: Extensions noResults: No extension found for the search criteria tabs: catalog: Catalog installed: Installed banners: install: Extension {name} has been installed. uninstall: Extension {name} has been removed. labels: install: Install uninstall: Remove upgrade: Upgrade loading: install: Installing... uninstall: Removing... upgrade: Upgrading... moreInfo: More information containers: title: Containers sortableTables: noRows: There are no containers to show logs: title: Container Logs loading: Loading container logs... noLogs: No logs available refresh: Refresh fetchError: Failed to fetch container logs manage: table: header: state: State containerName: Name image: Image ports: Port(s) started: Uptime actions: Actions volumes: title: Volumes sortableTables: noRows: There are no volumes to show manage: table: header: volumeName: Volume Name driver: Driver mountpoint: Mount Point created: Created manager: table: action: browse: Browse Files delete: Delete files: title: Volume Files loading: Loading volume files... volumeNotFound: 'Volume "{name}" not found' checkError: Error checking volume status listError: 'Error listing files: {error}' noFiles: This volume is empty table: header: name: Name size: Size modified: Modified permissions: Permissions images: title: Images sortableTables: noRows: There are no images to show state: k8sUnready: Waiting for Kubernetes to be ready imagesUnready: Waiting for image manager to be ready unknown: 'Error: Unknown state; please reload.' close: Close Output to Continue action: add: Add Image manager: close: Close Output to Continue title: Image Output input: pull: label: 'Name of image to pull:' placeholder: 'registry.example.com/namespace/image' button: Pull build: label: 'Name of image to build:' placeholder: 'registry.example.com/namespace/image:tag' button: Build table: label: All images header: imageName: Image tag: Tag imageId: Image ID size: Size action: push: Push delete: Delete scan: Scan... add: title: Add Image action: build: Build pull: Pull pastTense: build: Built pull: Pulled loadingText: '{action}ing image...' successText: '{action} image' errorText: Error trying to { action } ''{ image }'' - see console output for more information scan: title: Scan details for ''{ image }'' loadingText: Scanning ''{ image }'' errorText: Error trying to scan ''{ image }'' - see console output for more information results: headers: severity: Severity package: Package vulnerabilityId: Vulnerability ID installed: Installed fixed: Fixed labels: critical: CRITICAL high: HIGH medium: MEDIUM low: LOW issuesFound: Issues Found details: description: Description primaryUrl: Primary URL references: References k8s: title: Kubernetes Settings dialog: ok: OK cancel: Cancel portForwarding: title: Port Forwarding sortableTables: noRows: There are no port forwarding entries to show general: title: Welcome to Rancher Desktop by SUSE description: Rancher Desktop provides Kubernetes and image management through the use of a desktop application. about: title: About versions: title: Versions component: Component version: Version cli: CLI helm: Helm machine: Machine releaseNotes: 'View release notes' os: mac: macOS windows: Windows linux: Linux downloadImageList: title: Image Lists downloadCLI: title: CLI Downloads application: behavior: autoStart: legendText: Startup label: Automatically start at login background: legendText: Background legendTooltip: > Hide the app window when Rancher Desktop is running in the background. It can be opened via the Notification Icon (if visible) or by running Rancher Desktop from the Applications menu. startInBackground: label: Start in the background windowQuitOnClose: label: Quit when closing application window notificationIcon: legendText: Notification Icon label: Hide Notification Icon theme: legendText: Appearance options: system: label: System description: Follow the operating system setting light: label: Light description: Always use light mode dark: label: Dark description: Always use dark mode virtualMachine: networkingTunnel: legend: Networking Tunnel label: Enable networking tunnel mount: type: legend: Mount Type options: reverse-sshfs: label: reverse-sshfs description: Exposes the filesystem by running an SFTP server. 9p: label: 9p description: Exposes the filesystem by using QEMU's virtio-9p-pci devices. options: cacheMode: legend: Cache Mode tooltip: Caching policy to be used options: none: none loose: loose fscache: fscache mmap: mmap mSizeInKib: legend: Memory Size In KiB tooltip: Maximum package size in KiB protocolVersion: legend: Protocol Version tooltip: 9P protocol version options: 9p2000: 9p2000 9p2000u: '9p2000.u' 9p2000L: '9p2000.L' securityModel: legend: Security Model tooltip: Security model used for the export path options: passthrough: passthrough 'mapped-xattr': mapped-xattr 'mapped-file': mapped-file none: none virtiofs: label: virtiofs description: Exposes the filesystem by using an Apple Virtualization framework shared directory device. proxy: legend: WSL Proxy label: Enable the proxy used by rancher-desktop addressTitle: Proxy address address: Address port: Port authTitle: Authentication information username: Username password: Password noproxy: legend: No proxy hostname list placeholder: Hostname to not redirect to the proxy errors: duplicate: Error, item is duplicate. type: legend: Virtual Machine Type options: qemu: label: QEMU description: Use the QEMU emulator. vz: label: VZ description: Use the Apple Virtualization framework. useRosetta: legend: VZ Option label: Enable Rosetta support containerEngine: label: Container Engine options: moby: label: dockerd (moby) description: Docker API; use with Docker CLI. containerd: label: containerd description: Namespaces for container images; use with nerdctl. webAssembly: label: WebAssembly (Wasm) enabled: Enabled description: Please read the documentation before enabling the experimental WebAssembly feature! allowedImages: label: Allowed Image Patterns patterns: placeholder: Type the image pattern errors: duplicate: Error, item is duplicate. enable: Enable alert: The image name needs to match one of the patterns defined in the Allowed Images preference tab. pathManagement: label: Configure PATH tooltip: Rancher Desktop ships with tools, such as kubectl, nerdctl, helm and docker. In order to use these tools, $HOME/.rd/bin must be in your PATH. options: rcFiles: label: Automatic description: Rancher Desktop edits your shell profile for you. Restart any open shells for changes to take effect. manual: label: Manual description: 'Rancher Desktop does not change your PATH configuration; add $HOME/.rd/bin to your path manually.' accept: Accept legacyIntegrations: title: Legacy Integrations Found messageFirstPart: Rancher Desktop detected legacy tool symlinks in messageSecondPart: but did not have the permissions required to remove them. messageThirdPart: > Legacy symlinks have the potential to cause path conflicts. Please remove them at your earliest convenience. details: > Rancher Desktop creates symlinks from bundled tools, such as kubectl and docker, to a directory in order to make them usable. This directory changed in Rancher Desktop 1.3.0. If you are seeing this message, Rancher Desktop was unable to remove the symlinks from the old directory automatically. You should remove them manually to prevent future path conflicts. ok: OK sudoPrompt: title: Administrative Access Required message: 'Rancher Desktop requires administrative access ("sudo access") for the following reasons:' messageSecondPart: The prompt will be displayed once this window is closed. Cancelling the prompt or disabling administrative access requires you to switch the docker context to rancher-desktop in order to continue using Rancher Desktop. explanation: 'This will modify the following paths:' buttonText: OK unmetPrerequisites: title: Rancher Desktop is unable to start message: 'Rancher Desktop cannot start because requirements are missing or not configured:' action: 'Please ensure all requirements are met and try again. Rancher Desktop will now close.' buttonText: OK accountAndKeys: title: Account and API Keys account: title: Account change: Change Password apiKeys: title: API Keys notAllowed: You do not have permission to manage API Keys add: description: label: Description placeholder: Optionally enter a description to help you identify this API Key label: Create API Key expiry: label: Automatically expire options: never: Never day: A day from now month: A month from now year: A year from now custom: Custom maximum: "{value} - Maximum allowed" customExpiry: options: minute: Minutes hour: Hours day: Days month: Months year: Years scope: Scope noScope: No Scope info: accessKey: Access Key secretKey: Secret Key bearerToken: Bearer Token saveWarning: Save the info above! This is the only time you'll be able to see it. If you lose it, you'll need to create a new API key. keyCreated: A new API Key has been created bearerTokenTip: "Access Key and Secret Key can be sent as the username and password for HTTP Basic auth to authorize requests. You can also combine them to use as a Bearer token:" ttlLimitedWarning: The Expiry time for this API Key was reduced due to system configuration authConfig: accessMode: label: 'Configure who should be able to log in and use {vendor}' required: Restrict access to only the authorized users & groups restricted: 'Allow members of clusters and projects, plus authorized users & groups' unrestricted: Allow any valid user allowedPrincipalIds: title: Authorized Users & Groups associatedWarning: 'Note: The {provider} user you authenticate as will be associated as an alternate way to log in to the {vendor} user you are currently logged in as {username}; all the global permissions, project, and cluster role bindings of this {vendor} user will also apply to the {provider} user.' github: clientId: label: Client ID clientSecret: label: Client Secret form: app: label: Application name value: 'Anything you like, e.g. My {vendor}' calllback: label: Authorization callback URL description: label: Application description value: 'Optional, can be left blank' homepage: label: Homepage URL instruction: 'Fill in the form with these values:' prefix: 1:
  • Open GitHub application settings in a new window.
  • 2:
  • Click on the "OAuth Apps" tab.
  • 3:
  • Click the "New OAuth App" button.
  • suffix: 1:
  • Click "Register application"
  • 2:
  • Copy and paste the Client ID and Client Secret of your newly created OAuth app into the fields below
  • host: label: GitHub Enterprise Host placeholder: e.g. github.mycompany.example target: label: Which version of GitHub do you want to use? private: A private installation of GitHub Enterprise public: Public GitHub.com table: server: Server clientId: Client ID googleoauth: adminEmail: Admin Email domain: Domain oauthCredentials: label: OAuth Credentials tip: The OAuth Credentials JSON can be found in the Google API developers console. serviceAccountCredentials: label: Service Account Credentials tip: The Service Account Credentials JSON can be found in the service accounts section of the Google API developers console. steps: 1: title: 'Open applications settings in a new window' body: 1: Login to your account. Navigate to "APIs & Services" and then select "OAuth consent screen". 2: 'Authorized domains:' 3: 'Application homepage link: ' 4: 'Under Scopes for Google APIs, enable "email", "profile", and "openid".' 5: 'Click on "Save".' topPrivateDomain: 'Top private domain of:' 2: title: 'Navigate to the "Credentials" tab to create your OAuth client ID' body: 1: 'Select the "Create Credentials" dropdown, and select "OAuth clientID", then select "Web application".' 2: 'Authorized JavaScript origins:' 3: 'Authorized redirect URIs:' 4: 'Click "Create", and then click on the "Download JSON" button.' 5: 'Upload the downloaded JSON file in the OAuth credentials box.' 3: title: 'Create Service Account credentials' introduction: 'Follow this guide to:' body: 1: Create a service account. 2: Generate a key for the service account. 3: Add the service account as an OAuth client in your google domain. ldap: freeipa: Configure a FreeIPA server activedirectory: Configure an Active Directory account openldap: Configure an OpenLDAP server defaultLoginDomain: label: Default Login Domain placeholder: eg mycompany hint: This domain will be used if a user logs in without specifying one. cert: Certificate disabledStatusBitmask: Disabled Status Bitmask groupDNAttribute: Group DN Attribute groupMemberMappingAttribute: Group Member Mapping Attribute groupMemberUserAttribute: Group Member User Attribute groupSearchBase: label: Group Search Base placeholder: 'ou=groups,dc=mycompany,dc=com' hostname: Hostname/IP loginAttribute: Login Attribute nameAttribute: Name Attribute nestedGroupMembership: label: Nested Group Membership options: direct: Search only direct group memberships nested: Search direct and nested group memberships objectClass: Object Class password: Password port: Port customizeSchema: Customize Schema users: Users groups: Groups searchAttribute: Search Attribute searchFilter: Search Filter serverConnectionTimeout: Server Connection Timeout serviceAccountDN: Service Account Distinguished Name serviceAccountPassword: Service Account Password serviceAccountInfo: '{vendor} needs a service account that has read-only access to all of the domains that will be able to login, so that we can determine what groups a user is a member of when they make a request with an API key.' starttls: label: Start TLS tip: Upgrades non-encrypted connections by wrapping with TLS during the connection process. Cannot be used in conjunction with TLS. tls: TLS userEnabledAttribute: User Enabled Attribute userMemberAttribute: User Member Attribute userSearchBase: label: User Search Base placeholder: 'e.g. ou=users,dc=mycompany,dc=com' username: Username usernameAttribute: Username Attribute table: server: Server clientId: Client ID saml: entityID: Entity ID Field UID: UID Field adfs: Configure an AD FS account api: '{vendor} API Host' cert: label: Certificate placeholder: Paste in the certificate, starting with -----BEGIN CERTIFICATE----- displayName: Display Name Field groups: Groups Field key: label: Private Key placeholder: Paste in the private key, typically starting with -----BEGIN RSA PRIVATE KEY----- keycloak: Configure a Keycloak account metadata: label: Metadata XML placeholder: Paste in the IDP Metadata XML okta: Configure an Okta account ping: Configure a Ping account shibboleth: Configure a Shibboleth account showLdap: Configure an OpenLDAP Server userName: User Name Field azuread: tenantId: Tenant ID applicationId: Application ID endpoint: Endpoint graphEndpoint: Graph Endpoint tokenEndpoint: Token Endpoint authEndpoint: Auth Endpoint oidc: oidc: Configure an OIDC account keycloakoidc: Configure a Keycloak OIDC account rancherUrl: Rancher URL clientId: Client ID clientSecret: Client Secret customEndpoint: label: Endpoints custom: Specify standard: Generate keycloak: url: Keycloak URL realm: Keycloak Realm issuer: Issuer authEndpoint: Auth Endpoint cert: label: Certificate placeholder: Paste in the certificate, starting with -----BEGIN CERTIFICATE----- key: label: Private Key placeholder: Paste in the private key, typically starting with -----BEGIN RSA PRIVATE KEY----- stateBanner: disabled: 'The {provider} authentication provider is currently disabled.' enabled: 'The {provider} authentication provider is currently enabled.' testAndEnable: Test and Enable Authentication noneEnabled: Local Authentication is always enabled, but you may select another additional authentication provider from those shown below. localEnabled: '{vendor} is configured to allow access to accounts in its local database.' manageLocal: Manage Accounts authGroups: actions: refresh: Refresh Group Memberships assignRoles: Assign Global Roles assignEdit: assignTitle: Assign Global Roles To Group assignTo: title: |- {count, plural, =1 { Assign Cluster To… } other { Assign {count} Clusters To… } } labelsTitle: |- {count, plural, =1 { Assign Cluster To… } other { Assign {count} Clusters To… } } workspace: Workspace asyncButton: apply: action: Apply success: Applied waiting: Applying… continue: action: Continue success: Saved waiting: Saving… copy: action: Click to Copy success: Copied! create: action: Create success: Created waiting: Creating… default: action: Action error: Error success: Success waiting: Waiting delete: action: Delete success: Deleted waiting: Deleting… disable: action: Disable success: Disabled waiting: Disabling… activate: action: Activate waiting: Activating… success: Activated deactivate: action: Deactivate waiting: Deactivating… success: Deactivated done: action: Done success: Saved waiting: Saving… download: action: Download success: Saving waiting: Downloading… edit: action: Save success: Saved waiting: Saving… enable: action: Enable success: Enabled waiting: Enabling… finish: action: Finish success: Finished waiting: Finishing… import: action: Import success: Imported waiting: Importing… install: action: Install success: Installing waiting: Starting… refresh: action: '' actionIcon: refresh error: '' errorIcon: error success: '' successIcon: checkmark waiting: '' waitingIcon: refresh remove: action: Remove success: Removed waiting: Removing… restore: action: Restore waiting: Restoring… success: Restored snapshot: action: Snapshot Now waiting: Snapshotting… success: Snapshot Creating uninstall: action: Uninstall success: Uninstalled waiting: Uninstalling… update: action: Update success: Updated waiting: Updating… upgrade: action: Upgrade success: Upgrading waiting: Starting… backupRestoreOperator: backupFilename: Backup Filename deleteTimeout: label: Delete Timeout tip: Seconds to wait for a resource delete to succeed before removing finalizers to force deletion. deployment: rancherNamespace: Rancher ResourceSet Namespace size: Size storage: label: Default Storage Location options: defaultStorageClass: 'Use the default storage class ({name})' none: No default storage location pickPV: Use an existing persistent volume pickSC: Use an existing storage class s3: Use an S3-compatible object store persistentVolume: label: Persistent Volume storageClass: label: Storage Class tip: 'Configure a storage location where all backups are saved by default. You will have the option to override this with each backup, but will be limited to using an S3-compatible object store.' warning: 'This {type} does not have its reclaim policy set to "Retain". Your backups may be lost if the volume is changed or becomes unbound.' encryption: Encryption encryptionConfigName: backuptip: 'Any secret in the cattle-resource-system namespace that has an encryption-provider-config.yaml key.
    The contents of this file are necessary to perform a restore from this backup, and are not stored by Rancher Backup.' label: Encryption Config Secret options: none: Store the contents of the backup unencrypted secret: 'Encrypt backups using an Encryption Config Secret (Recommended)' restoretip: 'If the backup was performed with encryption enabled, a secret containing the same encryption-provider-config should be used during restore.' warning: 'The contents of this file are necessary to perform a restore from this backup, and are not stored by Rancher Backup.' lastBackup: Last Backup nextBackup: Next Backup noResourceSet: You must define a ResourceSet in this namespace to create a backup CR. prune: label: Prune tip: Delete the resources managed by Rancher that are not present in the backup. (Recommended) resourceSetName: Resource Set restoreFrom: default: The default storage target existing: An existing backup config s3: An S3-compatible object store retentionCount: label: Retention Count units: |- {count, plural, =1 { File } other { Files } } s3: bucketName: Bucket Name credentialSecretName: Credential Secret endpoint: Endpoint endpointCA: Endpoint CA folder: Folder insecureTLSSkipVerify: Skip TLS Verifications region: Region storageLocation: Storage Location titles: backupLocation: Backup Source location: Storage Location s3: S3 schedule: label: Schedule options: disabled: One-Time Backup enabled: Recurring Backups placeholder: e.g. @midnight or 0 0 * * * storageSource: configureS3: Use an S3-compatible object store useBackup: Use the s3 location specified on the Backup CR useDefault: Use the default storage location configured during installation targetBackup: Target Backup catalog: app: managed: Managed section: notes: Release Notes readme: Chart README resources: Resources values: Values YAML chart: header: charts: Charts info: appVersion: Application Version chartVersions: label: Chart Versions showMore: Show More showLess: Show Less home: Home maintainers: Maintainers related: Related chartUrls: Chart keywords: Keywords errors: clusterToolExists: This chart has a fixed namespace and name. A matching application has been found and any changes will be made to it. charts: all: All categories: all: All Categories certified: other: Other partner: Partner rancher: '{vendor}' header: Deploy Chart noCharts: 'There are no charts available, have you added any repos?' noWindows: Your catalogs do not contain any charts capable of being deployed on a Windows cluster. search: Filter install: action: goToUpgrade: Edit/Upgrade appReadmeMissing: This chart doesn't have any additional chart information. appReadmeTitle: Chart Information (Helm README) chart: Chart error: requiresFound: '{name} must be installed before you can install this chart.' requiresMissing: 'This chart requires another chart that provides {name}, but none was found.' insufficientCpu: 'This chart requires {need, number} CPU cores, but the cluster only has {have, number} available.' insufficientMemory: 'This chart requires {need} of memory, but the cluster only has {have} available.' header: install: 'Install {name}' installGeneric: Install Chart upgrade: 'Upgrade {name}' helm: atomic: Atomic cleanupOnFail: Cleanup on Failure crds: Apply custom resource definitions dryRun: Dry Run force: Force historyMax: label: Keep last unit: |- {value, plural, =1 { revision } other { revisions } } hooks: Execute chart hooks openapi: Validate OpenAPI schema resetValues: Reset Values timeout: label: Timeout unit: |- {value, plural, =1 { second } other { seconds } } wait: Wait namespaceIsInProject: "This chart's target namespace, {namespace}, already exists and cannot be added to a different project." project: Install into Project section: chartOptions: Edit Options valuesYaml: Edit YAML diff: Compare Changes slideIn: dock: Dock to bottom steps: basics: label: Metadata subtext: Set App metadata description: This process will help {action, select, install { create } upgrade { upgrade } update { update } } the {existing, select, true { app} false { chart} }. Start by setting some basic information used by {vendor} to manage the App. nsCreationDescription: "To install the app into a new namespace enter it's name and select it in the Namespace field." createNamespace: "Namespace {namespace} will be created." helmValues: label: Values subtext: Change how the App works description: Configure Values used by Helm that help define the App. chartInfo: button: View Chart Info label: Chart Info helmCli: checkbox: Customize Helm options before install label: Helm Options subtext: Change how the app is deployed description: Supply additional deployment options version: Version versions: current: '{ver} (Current)' linux: '{ver} (Linux-only)' windows: '{ver} (Windows-only)' operation: tableHeaders: action: Action releaseName: Release Name releaseNamespace: Release Namespace repo: action: refresh: Refresh all: All gitBranch: label: Git Branch placeholder: e.g. master gitRepo: label: Git Repo URL placeholder: 'e.g. https://github.com/your-company/charts.git' name: rancher-charts: '{vendor}' rancher-partner-charts: Partners rancher-rke2-charts: RKE2 target: git: Git Repository containing Helm chart definitions http: http(s) URL to an index generated by Helm label: Target url: label: Index URL placeholder: 'e.g. https://charts.rancher.io' tools: header: Cluster Tools action: install: Install upgrade: Upgrade/Edit edit: Edit remove: Remove manage: Manage changePassword: title: Change Password cancel: Cancel deleteKeys: label: Delete all existing API keys changeOnLogin: label: Ask user to change their password on first login generatePassword: label: Generate a random password currentPassword: label: Current Password userGen: newPassword: label: New Password confirmPassword: label: Confirm Password randomGen: generated: label: Generated Password newGeneratedPassword: Suggest a password errors: missmatchedPassword: Passwords do not match failedToChange: Failed to change password failedDeleteKey: Failed to delete key failedDeleteKeys: Failed to delete keys chartHeading: overview: Overview poweredBy: "Powered by:" cis: addTest: Add Test ID alertNeeded: |- Alerting must be enabled within the CIS chart values.yaml. This requires that the {vendor} Monitoring and Alerting app is installed and the Receivers and Routes are configured to send out alerts. alertOnComplete: Alert on scan completion alertOnFailure: Alert on scan failure benchmarkVersion: Benchmark Version clusterProvider: Cluster Provider cronSchedule: label: Schedule placeholder: "e.g. 0 * * * *" customConfigMap: Custom Benchmark ConfigMap deleteBenchmarkWarning: |- {count, plural, =1 { Any profiles using this benchmark version will no longer work. } other { Any profiles using these benchmark versions will no longer work } } deleteProfileWarning: |- {count, plural, =1 { Any scheduled scans using this profile will no longer work. } other { Any scheduled scans using either of these profiles will no longer work. } } downloadAllReports: Download All Saved Reports downloadLatestReport: Download Latest Report downloadReport: Download Report maxKubernetesVersion: Maximum allowed Kubernetes version minKubernetesVersion: Minimum required Kubernetes version noProfiles: There are no valid ClusterScanProfiles for this cluster type to select. noReportFound: No scan report found profile: Profile reports: Reports retention: Retention Count scan: description: Description fail: Fail lastScanTime: Last Scan Time notApplicable: N/A number: Number pass: Pass remediation: Remediation scanDate: Scan Date scanReport: Scan Report skip: Skip total: Total warn: Warn scheduling: disable: Run scan once enable: Run scan on a schedule scoreWarning: label: Scan state for "warn" results protip: Scans with no failures will be marked "Pass" by default even if some of the tests generate "warn" output. This behavior can be changed by selecting the "fail" option from this section. testID: Test ID testsSkipped: Tests Skipped testsToSkip: Tests to Skip cluster: agentEnvVars: label: Agent Environment detail: Add additional environment variables to the agent container. This is most commonly useful for configuring a HTTP proxy. custom: nodeRole: label: Node Role detail: Choose what roles the node will have in the cluster. The cluster needs to have at least one node with each role. advanced: label: Advanced detail: Additional control over how the node will be registered. These values will often need to be different for each node registered. registrationCommand: label: Registration Command detail: Run this command on each of the existing machines you want to register. insecure: "Insecure: Select this to skip TLS verification if your server has a self-signed certificate." credential: aws: accessKey: label: Access Key placeholder: Your AWS Access Key defaultRegion: help: The default region to use when creating clusters. Also contacted to verify that this credential works. label: Default Region secretKey: label: SecretKey placeholder: Your AWS Secret Key digitalocean: accessToken: help: Paste in a Personal Access Token from the DigitalOcean Applications & API screen. label: Access Token placeholder: Your DigitalOcean API Access Token label: Cloud Credential name: label: Credential Name placeholder: Name for this credential (optional) vmwarevsphere: server: label: vCenter or ESXi Server placeholder: vcenter.domain.com port: label: Port username: label: Username password: label: Password note: 'Note: The free ESXi license does not support API access. Only servers with a valid or evaluation license are supported.' description: label: Cluster Description placeholder: Any text you want that better describes this cluster import: commandInstructions: 'Run the kubectl command below on an existing Kubernetes cluster running a supported Kubernetes version to import it into {vendor}:' commandInstructionsInsecure: 'If you get a "certificate signed by unknown authority" error, your {vendor} installation has a self-signed or untrusted SSL certificate. Run the command below instead to bypass the certificate verification:' clusterRoleBindingInstructions: 'If you get permission errors creating some of the resources, your user may not have the cluster-admin role. Use this command to apply it:' clusterRoleBindingCommand: 'kubectl create clusterrolebinding cluster-admin-binding --clusterrole cluster-admin --user ' importAction: Import Existing kubernetesVersion: label: Kubernetes Version toolsTip: Use the new Cluster Tools to manage and install Monitoring, Logging and other tools name: label: Cluster Name placeholder: A unique name for the cluster machineConfig: aws: sizeLabel: |- {apiName}: {cpu}, {memory, number} GiB Memory, {storageSize, plural, =0 {EBS-Only} other {{storageSize, number} GiB {storageType}} } digitalocean: sizeLabel: |- {plan, select, s {Basic: } g {General: } gd {General: } c {CPU: } m {Memory: } so {Storage: } standard {Standard: } other {} }{memoryGb} GB, {vcpus, plural, =1 {# vCPU} other {# vCPUs} }, {disk} GB Disk ({value}) machinePool: name: label: Pool Name placeholder: A random one will be generated by default nodeTotals: label: controlPlane: '{count} Control Plane' etcd: '{count} etcd' worker: '{count} Worker' tooltip: controlPlane: |- {count, plural, =0 { A cluster needs at least one control plane node to be usable. } =1 { A cluster with only one control plane node is not fault-tolerant. } other {} } etcd: |- {count, plural, =0 { A cluster needs at least one etcd node to be usable. } =1 { A cluster with only one etcd node is not fault-tolerant. } =2 { Clusters should have an odd number of nodes. A cluster with 2 etcd nodes is not fault-tolerant. } =3 {} =4 { Clusters should have an odd number of nodes. } =5 {} =6 { Clusters should have an odd number of nodes. } =7 {} other { More than 7 etcd nods is not recommended. } } worker: |- {count, plural, =0 { A cluster needs at least one worker node to be usable. } =1 { A cluster with only one worker node is not fault-tolerant. } other {} } provider: aliyunecs: Aliyun ECS aliyunkubernetescontainerservice: Alibaba ACK aliyun: Alibaba ACK amazonec2: Amazon EC2 amazoneks: Amazon EKS aws: Amazon AWS azure: Azure azureaks: Azure AKS aks: Azure AKS baiducloudcontainerengine: Baidu CCE baidu: Baidu CCE cloudca: Cloud.ca custom: Custom digitalocean: DigitalOcean docker: Docker eks: Amazon EKS exoscale: Exoscale google: Google GCE googlegke: Google GKE gke: Google GKE huaweicce: Huawei CCE import: Generic imported: Imported k3s: K3s kubeAdmin: KubeADM linode: Linode local: Local minikube: Minikube oci: Oracle Cloud Infrastructure openstack: OpenStack opentelekomcloudcontainerengine: Open Telekom Cloud CCE otccce: Open Telekom Cloud CCE oracleoke: Oracle OKE otc: Open Telekom Cloud other: Other packet: Packet pinganyunecs: Pinganyun ECS rackspace: RackSpace rancherkubernetesengine: RKE rke2: RKE2 rke: RKE1 rkeWindows: Windows softlayer: SoftLayer tencenttke: Tencent TKE upcloud: UpCloud vmwarevsphere: vSphere zstack: ZStack providerGroup: create-custom1: Use existing nodes and create a cluster using RKE create-custom2: Use existing nodes and create a cluster using RKE2 create-kontainer: Create a cluster in a hosted Kubernetes provider register-kontainer: Register an existing cluster in a hosted Kubernetes provider create-rke1: Provision new nodes and create a cluster using RKE create-rke2: Provision new nodes and create a cluster using RKE2 create-template: Use a Catalog Template to create a cluster register-custom: Import any Kubernetes cluster rke2: systemService: rke2-coredns: 'CoreDNS' rke2-ingress-nginx: 'NGINX Ingress Controller' rke2-kube-proxy: 'Kube Proxy' rke2-metrics-server: 'Metrics Server' tabs: ace: Authorized Endpoint advanced: Advanced agentEnv: Agent Environment Vars basic: Basics cluster: Cluster Configuration etcd: etcd networking: Networking machinePools: Machine Pools registry: Private Registry upgrade: Upgrade Strategy clusterIndexPage: hardwareResourceGauge: consumption: "{useful} of {total} {units} {suffix}" cores: Cores pods: Pods ram: Memory used: Used reserved: Reserved header: Cluster Dashboard resourceGauge: totalResources: Total Resources sections: capacity: label: Capacity events: label: Events resource: label: Resource date: label: Date alerts: label: Alerts clusterMetrics: label: Cluster Metrics etcdMetrics: label: Etcd Metrics k8sMetrics: label: Kubernetes Components Metrics gatekeeper: buttonText: Configure Gatekeeper disabled: OPA Gatekeeper is not configured. label: OPA Gatekeeper Constraint Violations noRows: There are no constraints with violations to show. nodes: label: Unhealthy Nodes noRows: There are no unhealthy nodes to show. configmap: tabs: data: label: Data protip: Use this area for anything that's UTF-8 text data binaryData: label: Binary Data containerResourceLimit: cpuPlaceholder: e.g. 1000 helpText: Configure how much resources the container can consume by default. helpTextDetail: The amount of resources the container can consume by default. label: Container Default Resource Limit limitsCpu: CPU Limit limitsMemory: Memory Limit memPlaceholder: e.g. 128 requestsCpu: CPU Reservation requestsMemory: Memory Reservation cruResource: backToForm: Back to Form backBody: You will lose any changes made to the YAML. cancelBody: You will lose any changes made to the YAML. confirmBack: "Okay" confirmCancel: "Okay" reviewForm: "Keep editing YAML" reviewYaml: "Keep editing YAML" previewYaml: Edit as YAML detailText: collapse: Hide binary: '' empty: '' unsupported: '' plusMore: |- {n, plural, =1 {+ 1 more char} other {+ {n, number} more chars} } etcdInfoBanner: hasLeader: "Etcd has a leader:" leaderChanges: "Number of leader changes:" failedProposals: "Number of failed proposals:" fleet: cluster: summary: Resource Summary nonReady: Non-Ready Bundles fleetSummary: state: success: 'Ready' info: 'Transitioning' warning: 'Warning' error: 'Error' unknown: 'Unknown' gitRepo: tabs: resources: Resources unready: Non-Ready auth: label: Authentication git: Git Authentication helm: Helm Authentication caBundle: label: Certificates placeholder: "Paste in one or more certificates, starting with -----BEGIN CERTIFICATE----" paths: label: Paths placeholder: e.g. /directory/in/your/repo addLabel: Add Path empty: The root of the repo is used by default. To use one or more different directories, add them here. repo: label: Repository URL placeholder: 'e.g. https://github.com/rancher/fleet-examples.git' ref: label: Watch branch: A Branch revision: A Revision branchLabel: Branch Name branchPlaceholder: e.g. master revisionLabel: Tag or Commit Hash revisionPlaceholder: e.g. v1.0.0 serviceAccount: label: Service Account Name placeholder: "Optional: Use a service account in the target clusters" targetNamespace: label: Target Namespace placeholder: "Optional: Require all resources to be in this namespace" target: selectLabel: Target advanced: Advanced cluster: Cluster clusterGroup: Cluster Group label: Deploy To labelLocal: Deploy With targetDisplay: advanced: Advanced cluster: "Cluster" clusterGroup: "Group" all: All none: None local: Local tls: label: TLS Certificate Verification verify: Require a valid certificate specify: Specify additional certificates to be accepted skip: Accept any certificate (insecure) workspace: label: Workspace clusterGroup: selector: label: Cluster Selectors matchesAll: Matches all {total, number} existing clusters matchesNone: Matches no existing clusters matchesSome: |- {matched, plural, =1 {Matches 1 of {total, number} existing clusters: "{sample}"} other {Matches {matched, number} of {total, number} existing clusters, including "{sample}"} } footer: docs: Docs download: Download CLI forums: Forums issue: File an Issue slack: Slack gatekeeperConstraint: match: title: Match tab: enforcementAction: title: Enforcement Action rules: title: Rules sub: labelSelector: addLabel: Add Label title: Label Selector namespaces: sub: excludedNamespaces: Excluded Namespaces namespaces: Namespaces namespaceSelector: addNamespace: Add Namespace title: Namespace Selector scope: title: Scope title: Namespaces parameters: addParameter: Add Parameter editAsForm: Edit as Form editAsYaml: Edit as YAML title: Parameters template: Template violations: title: Violations gatekeeperIndex: poweredBy: OPA Gatekeeper unavailable: OPA + Gatekeeper is not available in the system-charts catalog. violations: Violations glance: created: Created cpu: CPU Usage memory: Memory nodes: total: label: |- {count, plural, =1 { Node } other { Total Nodes } } pods: Pods provider: Provider version: Kubernetes Version monitoringDashboard: Monitoring Dashboard installMonitoring: Install Monitoring v1MonitoringInstalled: V1 Monitoring Installed grafanaDashboard: failedToLoad: Failed to load graph reload: Reload grafana: Grafana graphOptions: detail: Detail summary: Summary refresh: Refresh range: Range hpa: detail: currentMetrics: header: Current Metrics noMetrics: No Current Metrics metricHeader: '{source} Metric' metricIdentifier: name: label: Metric Name placeholder: e.g. packets-per-second selector: label: Add Selector metricTarget: averageVal: label: Average Value quantity: label: Quantity type: label: Type utilization: label: Average Utilization value: label: Value metrics: headers: metricName: Name objectKind: Object Kind objectName: Object Name quantity: Quantity resource: Resource Name targetName: Target Name value: Value source: Source objectReferance: api: label: Referent API Version placeholder: e.g. apps/v1beta1 kind: label: Referent Kind placeholder: e.g. Deployment name: label: Referent Name placeholder: e.g. php-apache tabs: labels: Labels metrics: Metrics target: Target workload: Workload types: cpu: CPU memory: Memory warnings: custom: In order to use custom metrics with HPA, you need to deploy the custom metrics server such as prometheus adapter. external: In order to use external metrics with HPA, you need to deploy the external metrics server such as prometheus adapter. noMetric: In order to use resource metrics with HPA, you need to deploy the metrics server. resource: The selected target reference does not have the correct resource requests on the spec. Without this the HPA metric will have no effect. workloadTab: current: Current Replicas last: Last Scale Time max: Maximum Replicas min: Minimum Replicas targetReference: Target Reference import: title: Import YAML defaultNamespace: label: Default Namespace success: |- Applied {count, plural, =1 {1 Resource} other {# Resources} } ingress: certificates: addCertificate: Add Certificate addHost: Add Host certificate: label: Certificate - Secret Name doesntExist: The selected certificate does not exist defaultCertLabel: Default Ingress Controller Certificate headers: certificate: Certificate hosts: Hosts host: label: Host placeholder: e.g. example.com label: Certificates removeHost: Remove defaultBackend: label: Default Backend noServiceSelected: No default backend is configured. port: label: Port placeholder: e.g. 80 or http targetService: label: Target Service doesntExist: The selected service does not exist warning: "Warning: Default backend is used globally for the entire cluster." rules: addPath: Add Path addRule: Add Rule headers: pathType: Path Type path: Path port: Port target: Target Service certificates: Certificates hostname: Hostname path: label: Path placeholder: e.g. /foo port: label: Port placeholder: e.g. 80 or http removePath: Remove requestHost: label: Request Host placeholder: e.g. example.com target: label: Target Service doesntExist: The selected service does not exist title: Rules rulesAndCertificates: title: Rules and Certificates defaultCertificate: default target: default: Default internalExternalIP: none: None istio: links: kiali: label: Kiali description: 'Visualization of services within a service mesh and how they are connected. For Kiali to display data, you need Prometheus installed. If you need a monitoring solution, install {vendor} monitoring.' jaeger: label: Jaeger description: Monitor and Troubleshoot microservices-based distributed systems. disabled: '{app} is not installed' cni: Enabled CNI customOverlayFile: label: Custom Overlay File tip: 'The overlay file allows for additional configuration on top of the base {vendor} Istio installation. You can utilize the IstioOperator API to make changes and additions for all components and apply those changes via this overlay YAML file.' description: '{vendor} Istio helm chart installs a minimal Istio configuration for you to get started integrating with your applications. If you would like to get additional information about Istio, visit https://istio.io/latest/docs/concepts/what-is-istio/' egressGateway: Enabled Egress Gateway ingressGateway: Enabled Ingress Gateway istiodRemote: Enabled istiodRemote kiali: Enabled Kiali pilot: Enabled Pilot policy: Enabled Policy poweredBy: Powered by Istio telemetry: Enabled Telemetry titles: components: Components customAnswers: Custom Answers advanced: Advanced Settings description: Description tracing: Enabled Jaeger Tracing (limited) v1Warning: Please uninstall the current Istio version in the istio-system namespace before attempting to install this version. labels: addLabel: Add Label addSetLabel: Add/Set Label addAnnotation: Add Annotation labels: title: Labels annotations: title: Annotations landing: clusters: title: Clusters provider: Provider kubernetesVersion: Kubernetes Version explorer: Explorer explore: Explore cores: |- {count, plural, =1 {core} other {cores}} seeWhatsNew: Learn more about the improvements and new capabilities in this version. whatsNewLink: "What's new in 2.5" learnMore: Learn More gettingStarted: title: Getting Started body: Take a look at the quick getting started guide. For Cluster Manager users, learn more about where you can find you favorite features in the Dashboard UI. community: title: Community Support docs: Docs forums: Forums commercial: title: Commercial Support body: Learn about commercial support landingPrefs: title: What do you want to see when you log in? body: "You can change where you land when you login:" options: homePage: Take me to the home page lastVisited: Take me to the area I last visited custom: "Take me to cluster:" welcomeToRancher: 'Welcome to {vendor}' logging: clusterFlow: noOutputsBanner: There are no cluster outputs in the selected namespace. flow: clusterOutputs: doesntExistTooltip: This cluster output doesn't exist label: Cluster Outputs matches: label: Matches addSelect: Add Include Rule addExclude: Add Exclude Rule filters: label: Filters outputs: doesntExistTooltip: This output doesn't exist label: Outputs install: k3sContainerEngine: K3S Container Engine enableAdditionalLoggingSources: Enable enhanced cloud provider logging dockerRootDirectory: Docker Root Directory elasticsearch: host: Host scheme: Scheme port: Port indexName: Index Name user: User password: Password from Secret caFile: label: CA File from Secret clientCert: label: Client Cert from Secret placeholder: Paste in the CA certificate clientKey: label: Client Key from Secret placeholder: Paste in the client key clientKeyPass: Client Key Pass from Secret kafka: brokers: Brokers defaultTopic: Default Topic saslOverSsl: SASL Over SSL scramMechanism: Scram Mechanism username: Username from Secret password: Password from Secret sslCaCert: label: CA Cert from Secret placeholder: Paste in the CA certificate sslClientCert: label: Cert from Secret placeholder: Paste in the client cert sslClientCertChain: label: Cert Chain from Secret placeholder: Paste in the client cert chain sslClientCertKey: Cert Key from Secret loki: url: URL tenant: Tenant username: User from Secret password: Password from Secret configureKubernetesLabels: Configure Kubernetes metadata in a Prometheus like format extractKubernetesLabels: Extract Kubernetes labels as Loki labels dropSingleKey: If a record only has 1 key, then just set the log line to the value and discard the key caCert: CA Cert from Secret cert: Cert from Secret key: Key from Secret awsElasticsearch: url: URL keyId: Key ID from Secret secretKey: Secret Key from Secret azurestorage: storageAccount: Account from Secret accessKey: Access Key from Secret container: Container path: Path storeAs: Store As cloudwatch: keyId: Key ID from Secret secretKey: Secret Key from Secret endpoint: Endpoint region: Region datadog: apiKey: API Key from Secret useSSL: Use SSL useCompression: Use Compression host: Host file: path: Path gcs: project: Project credentialsJson: Credentials from Secret bucket: Bucket path: Path overwriteExistingPath: Overwrite Existing Path kinesisStream: streamName: Stream Name keyId: Key ID from Secret secretKey: Secret Key from Secret logdna: apiKey: API Key hostname: Hostname app: App logz: url: URL port: Port token: Api Token from Secret enableCompression: Enable Compression newrelic: apiKey: API Key from Secret licenseKey: License Key from Secret baseURI: Base URI sumologic: endpoint: Endpoint from Secret sourceName: Source Name syslog: host: Host port: Port transport: Transport insecure: insecure trustedCaPath: CA Path from Secret format: title: Format type: Type addNewLine: Add New Line messageKey: Message Key buffer: title: Buffer tags: Tags chunkLimitSize: Chunk Limit Size chunkLimitRecords: Chunk Limit chunkLimitRecords totalLimitSize: Total Limit Size flushInterval: Flush Interval timekey: Timekey timekeyWait: Timekey Wait timekeyUseUTC: Timekey Use UTC s3: keyId: Key ID from Secret secretKey: Secret Key from Secret endpoint: Endpoint bucket: Bucket path: Path overwriteExistingPath: Overwrite Existing Path output: selectOutputs: Select Outputs selectBanner: Select to configure an output sections: target: Target access: Access certificate: Connection labels: Labels outputProviders: elasticsearch: Elasticsearch splunkHec: Splunk kafka: Kafka forward: Fluentd loki: Loki awsElasticsearch: Amazon Elasticsearch azurestorage: Azure Storage cloudwatch: Cloudwatch datadog: Datadog file: File gcs: GCS kinesisStream: Kinesis Stream logdna: LogDNA logz: LogZ newrelic: New Relic sumologic: SumoLogic syslog: Syslog s3: S3 unknown: Unknown overview: poweredBy: Banzai Cloud clusterLevel: Cluster-Level namespaceLevel: Namespace-Level provider: Provider splunk: host: Host port: Port protocol: Protocol index: Index token: Token from Secret insecureSsl: Insecure SSL indexName: Index Name source: Source caFile: CA File from Secret caPath: CA Path from Secret clientCert: Client Cert from Secret clientKey: Client Key from Secret forward: host: Host port: Port sharedKey: Shared Key from Secret username: Username from Secret password: Password from Secret clientCertPath: Client Cert Path from Secret clientPrivateKeyPath: Client Private Key Path from Secret clientPrivateKeyPassphrase: Client Private Key Passphrase from Secret longhorn: overview: title: Overview subtitle: "Powered By: Longhorn" linkedList: longhorn: label: 'Longhorn' description: 'Manage storage system via UI' na: Resource Unavailable login: howdy: Howdy! welcome: Welcome to {vendor} loggedOut: You have been logged out. loginAgain: Log in again to continue. error: An error occurred logging in. Please try again. useLocal: Use a local user loginWithProvider: Log in with {provider} username: Username password: Password loggingIn: Logging in... loggedIn: Logged in loginWithLocal: Log in with Local User useProvider: Use a {provider} user members: clusterMembers: Cluster Members createActionLabel: Add clusterPermissions: noDescription: User created - no description label: Cluster Permissions description: Controls what access users have to the Cluster createProjects: Create Projects manageClusterBackups: Manage Cluster Backups manageClusterCatalogs: Manage Cluster Catalogs manageClusterMembers: Manage Cluster Members manageNodes: Manage Nodes manageStorage: Manage Storage viewAllProjects: View All Projects viewClusterCatalogs: View Cluster Catalogs viewClusterMembers: View Cluster Members viewNodes: View Nodes owner: label: Owner description: Owners have full control over the Cluster and all resources inside it. member: label: Member description: Members can manage the resources inside the Cluster but not change the Cluster itself. custom: label: Custom description: Choose individual roles for this user. monitoring: accessModes: many: ReadWriteMany once: ReadWriteOnce readOnlyMany: ReadOnlyMany aggregateDefaultRoles: label: Aggregate to Default Kubernetes Roles tip: 'Adds labels to the ClusterRoles deployed by the Monitoring chart to aggregate to the corresponding default k8s admin, edit, and view ClusterRoles.' alerting: config: label: Alert Manager Config enable: label: Deploy Alertmanager secrets: additional: info: Secrets should be mounted at
    /etc/alertmanager/secrets/
    label: Additional Secrets existing: Choose an existing config secret info: | Create default config: A Secret containing your Alertmanager Config will be created in the
    cattle-monitoring-system
    namespace on deploying this chart under the name
    alertmanager-rancher-monitoring-alertmanager
    . By default, this Secret will never be modified on an uninstall or upgrade of this chart.
    Once you have deployed this chart, you should edit the Secret via the UI in order to add your custom notification configurations that will be used by Alertmanager to send alerts.

    Choose an existing config secret: You must specify a Secret that exists in the
    cattle-monitoring-system
    namespace. If the namespace does not exist, you will not be able to select an existing secret. label: Alertmanager Secret new: Create default config radio: label: Config Secret templates: keyLabel: File Name label: Template Files valueLabel: YAML Template title: Configure Alertmanager clusterType: label: Cluster Type placeholder: Select cluster type createDefaultRoles: label: Create Default Monitoring Cluster Roles tip: 'Creates monitoring-admin, monitoring-edit, and monitoring-view ClusterRoles that can be assigned to users to provide permissions to CRDs installed by the Monitoring chart.' etcdNodeDirectory: label: etcd Node Certificate Directory tooltip: 'For clusters that use RancherOS for the etcd nodes, this option should be set to
    /opt/rke/etc/kubernetes/ssl
    . Hybrid environments that require specifying multiple certificate directories (e.g. an etcd plane composed of both RancherOS and Ubuntu hosts) are not supported.' grafana: storage: annotations: PVC Annotations className: Storage Class Name existingClaim: Use Existing Claim finalizers: PVC Finalizers label: Grafana Storage mode: Access Mode selector: Selector size: Size subpath: Use Subpath type: Persistent Storage Types types: existing: Enable With Existing PVC statefulset: Enable with StatefulSet Template template: Enable with PVC Template volumeMode: Volume Mode volumeName: Volume Name title: Configure Grafana hostNetwork: label: Use Host Network For Prometheus Operator tip: If you are using a managed Kubernetes cluster with custom CNI (e.g. Calico), you must enable this option to allow a managed control plane to contact the admission webhook exposed by Prometheus Operator to mutate or validate incoming PrometheusRules. overview: alertsList: ends: label: Ends At label: Active Alerts message: label: Message severity: label: Severity start: label: Starts At linkedList: alertManager: description: Active Alerts label: Alertmanager grafana: description: Metrics Dashboards label: Grafana na: Resource Unavailable prometheusPromQl: description: PromQL Graph label: Prometheus Graph prometheusRules: description: Configured Rules label: Prometheus Rules prometheusTargets: description: Configured Targets label: Prometheus Targets subtitle: 'Powered By: Prometheus' title: Dashboard prometheus: config: adminApi: Admin API evaluation: Evaluation Interval ignoreNamespaceSelectors: help: 'Ignoring Namespace Selectors allows Cluster Admins to limit teams from monitoring resources outside of namespaces they have permissions to but can break the functionality of Apps that rely on setting up Monitors that scrape targets across multiple namespaces, such as Istio.' label: Namespace Selectors radio: enforced: 'Use: Monitors can access resources based on namespaces that match the namespace selector field' ignored: 'Ignore: Monitors can only access resources in the namespace they are deployed in' limits: cpu: CPU Limit memory: Memory Limit requests: cpu: Requested CPU memory: Requested Memory resourceLimits: Resource Limits retention: Retention retentionSize: Retention Size scrape: Scrape Interval storage: className: Storage Class Name label: Persistent Storage for Prometheus mode: Access Mode selector: Selector selectorWarning: 'If you are using a dynamic provisioner (e.g. Longhorn), no Selectors should be specified since a PVC with a non-empty selector can''t have a PV dynamically provisioned for it.' size: Size volumeMode: Volume Mode volumeName: Volume Name title: Configure Prometheus warningInstalled: | Warning: Prometheus Operators are currently deployed. Deploying multiple Prometheus Operators onto one cluster is not currently supported. Please remove all other Prometheus Operator deployments from this cluster before trying to install this chart. If you are migrating from an older version of {vendor} with Monitoring enabled, please disable Monitoring on this cluster completely before attempting to install this chart. receiver: fields: name: Name tls: label: SSL caFilePath: label: CA File Path placeholder: e.g. ./ca-file.csr certFilePath: label: Cert File Path placeholder: e.g. ./cert-file.crt keyFilePath: label: Key File Path placeholder: e.g. ./key-file.pfx secretsBanner: The file paths below must be referenced in
    alertmanager.alertmanagerSpec.secrets
    when deploying the Monitoring chart. For more information see our documentation. route: fields: groupBy: Group By groupInterval: Group Interval groupWait: Group Wait receiver: Receiver repeatInterval: Repeat Interval routesAndReceivers: Routes and Receivers monitors: Monitors installSteps: uninstallV1: stepTitle: Uninstall V1 stepSubtext: Uninstall Previous Monitoring warning1: V1 Monitoring is currently deployed. This needs to be uninstalled before V2 monitoring can be installed. warning2: Learn more about migrating to V2 Monitoring. success1: V1 monitoring successfully uninstalled. success2: Press Next to continue tabs: alerting: Alerting general: General grafana: Grafana prometheus: Prometheus v1Warning: 'Monitoring is currently deployed from Cluster Manager. If you are migrating from an older version of {vendor} with monitoring enabled, please disable monitoring in Cluster Manager before attempting to install the new {vendor} Monitoring chart in Cluster Explorer.' volume: modes: block: Block file: Filesystem monitoringReceiver: addButton: Add {type} custom: label: Custom title: Custom Config info: The YAML provided here will be directly appended to your receiver within the Alertmanager Config Secret. email: label: Email title: Email Config opsgenie: label: Opsgenie title: Opsgenie Config pagerduty: label: PagerDuty title: PagerDuty Config info: "See additional info on creating an Integration Key for PagerDuty." slack: label: Slack title: Slack Config info: "See additional info on creating Incoming Webhooks for Slack ." webhook: label: Webhook title: Webhook Config urlTooltip: For some webhooks this a url that points to the service DNS modifyNamespace: If
    rancher-alerting-drivers
    default values were changed, please update the url below in the format http://<new_service_name>.<new_namespace>.svc.<port>/<path> banner: To use MS Teams or SMS you will need to have
    rancher-alerting-drivers
    installed first. add: generic: Generic msTeams: MS Teams alibabaCloudSms: SMS auth: label: Auth authType: Auth Type username: Username password: Password none: label: None bearerToken: label: Bearer Token placeholder: e.g. secret-token basicAuth: label: Basic Auth bearerTokenFile: label: Bearer Token File placeholder: e.g. ./user_token shared: proxyUrl: label: Proxy URL placeholder: e.g. http://my-proxy/ sendResolved: label: Enable send resolved alerts monitoringRoute: groups: label: Group By info: This is the top-level Route used by Alertmanager as the default destination for any Alerts that do not match any other Routes. This Route must exist and cannot be deleted. interval: label: Group Interval matching: info: The root route has to match everything so matching cannot be configured. label: Match receiver: label: Receiver regex: label: Match Regex repeatInterval: label: Repeat Interval wait: label: Group Wait moveModal: title: Move to a new project? description: 'You are moving the following namespaces:' moveButtonLabel: Move nameNsDescription: name: label: Name placeholder: 'A unique name' namespace: label: Namespace placeholder: workspace: label: Workspace placeholder: description: label: Description placeholder: Any text you want that better describes this resource namespace: containerResourceLimit: Container Resource Limit project: label: Project resources: Resources enableAutoInjection: Enable Istio Auto Injection disableAutoInjection: Disable Istio Auto Injection move: Move namespaceFilter: selected: label: "{total} items selected" namespaceList: selectLabel: Namespace addLabel: Add Namespace node: detail: detailTop: containerRuntime: Container Runtime internalIP: Internal IP externalIP: External IP os: OS version: Version glance: consumptionGauge: used: Used amount: "{used} of {total} {unit}" cpu: CPU memory: MEMORY pods: PODS diskPressure: Disk Pressure kubelet: kubelet memoryPressure: Memory Pressure pidPressure: PID Pressure tab: conditions: Conditions images: Images metrics: Metrics info: label: Info key: architecture: Architecture bootID: Boot ID containerRuntimeVersion: Container Runtime Version kernelVersion: Kernel Version kubeProxyVersion: Kube Proxy Version kubeletVersion: Kubelet Version machineID: Machine ID operatingSystem: Operating System osImage: Image systemUUID: System UUID pods: Pods taints: Taints persistentVolume: pluginConfiguration: label: Plugin configuration customize: label: Customize affinity: label: Node Selectors addLabel: Add Node Selector assignToStorageClass: label: Assign to Storage Class mountOptions: label: Mount Options addLabel: Add Option accessModes: label: Access Modes readWriteOnce: Single Node Read-Write readOnlyMany: Many Nodes Read-Only readWriteMany: Many Nodes Read-Write shared: partition: label: Partition placeholder: e.g. 1; 0 for entire device readOnly: label: Read Only filesystemType: label: Filesystem Type placeholder: e.g. ext4 secretName: label: Secret Name placeholder: e.g. secret secretNamespace: label: Secret Namespace placeholder: e.g. default monitors: add: Add Monitor vsphereVolume: label: VMWare vSphere Volume volumePath: label: Volume Path placeholder: e.g. / storagePolicyName: label: Storage Policy Name placeholder: e.g. sp storagePolicyId: label: Storage Policy ID placeholder: e.g. sp1 csi: label: CSI (Unsupported) driver: label: Driver placeholder: e.g. driver.longhorn.io volumeHandle: label: Volume Handle placeholder: e.g. pvc-xxxx volumeAttributes: add: Add Volume Attribute nodePublishSecretName: label: Node Publish Secret Name placeholder: e.g. secret nodePublishSecretNamespace: label: Node Publish Secret Namespace placeholder: e.g. default nodeStageSecretName: label: Node Stage Secret Name placeholder: e.g. secret nodeStageSecretNamespace: label: Node Stage Secret Namespace placeholder: e.g. default controllerExpandSecretName: label: Controller Expand Secret Name placeholder: e.g. secret controllerExpandSecretNamespace: label: Controller Expand Secret Namespace placeholder: e.g. default controllerPublishSecretName: label: Controller Publish Secret Name placeholder: e.g. secret controllerPublishSecretNamespace: label: Controller Publish Secret Namespace placeholder: e.g. default cephfs: label: Ceph Filesystem (Unsupported) path: label: Path placeholder: e.g. /var user: label: User placeholder: e.g. root secretFile: label: Secret File placeholder: e.g. secret rbd: label: Ceph RBD (Unsupported) user: label: User placeholder: e.g. root keyRing: label: Key Ring placeholder: e.g. /etc/ceph/keyring pool: label: Pool placeholder: e.g. rbd image: label: Image placeholder: e.g. image fc: label: Fibre Channel (Unsupported) targetWWNS: add: Add Target WWN wwids: add: Add WWID lun: label: Lun placeholder: e.g. 2 flexVolume: label: Flex Volume (Unsupported) driver: label: Driver placeholder: e.g. driver options: add: Add Option flocker: label: Flocker (Unsupported) datasetName: label: Dataset Name placeholder: e.g. dataset datasetUUID: label: Dataset UUID placeholder: e.g. uuid glusterfs: label: Gluster Volume (Unsupported) endpoints: label: Endpoints placeholder: e.g. glusterfs-cluster path: label: Path placeholder: e.g. kube-vol iscsi: label: iSCSI Target (Unsupported) initiatorName: label: Initiator Name placeholder: iqn.1994-05.com.redhat:1df7a24fcb92 iscsiInterface: label: iSCSI Interface placeholder: e.g. interface chapAuthDiscovery: label: Chap Auth Discovery chapAuthSession: label: Chap Auth Session iqn: label: IQN placeholder: iqn.2001-04.com.example:storage.kube.sys1.xyz lun: label: Lun placeholder: e.g. 2 targetPortal: label: Target Portal placeholder: e.g. portal portals: add: Add Portal cinder: label: Openstack Cinder Volume (Unsupported) volumeId: label: Volume ID placeholder: e.g. vol quobyte: label: Quobyte Volume (Unsupported) volume: label: Volume placeholder: e.g. vol user: label: User placeholder: e.g. root group: label: Group placeholder: e.g. abc registry: label: Registry placeholder: e.g. abc photonPersistentDisk: label: Photon Volume (Unsupported) pdId: label: PD ID placeholder: e.g. abc portworxVolume: label: Portworx Volume (Unsupported) volumeId: label: Volume ID placeholder: e.g. abc scaleIO: label: ScaleIO Volume (Unsupported) volumeName: label: Volume Name placeholder: e.g. vol-0 gateway: label: Gateway placeholder: e.g. https://localhost:443/api protectionDomain: label: Protection Domain placeholder: e.g. pd01 storageMode: label: Storage Mode placeholder: e.g. ThinProvisioned storagePool: label: Storage Pool placeholder: e.g. sp01 system: label: System placeholder: e.g. scaleio sslEnabled: label: SSL Enabled storageos: label: StorageOS (Unsupported) volumeName: label: Volume Name placeholder: e.g. vol volumeNamespace: label: Volume Namespace placeholder: e.g. default nfs: label: NFS Share path: label: Path placeholder: e.g. /var server: label: Server placeholder: e.g. 10.244.1.4 longhorn: label: Longhorn volumeHandle: label: Volume Handle placeholder: e.g. pvc-xxxx options: label: Options addLabel: Add local: label: Local path: label: Path placeholder: e.g. /mnt/disks/ssd1 hostPath: label: HostPath pathOnTheNode: label: Path on the Node placeholder: /mnt/disks/ssd1 mustBe: label: The Path on the Node must be anything: 'Anything: do not check the target path' directory: A directory, or create if it does not exist file: A file, or create if it does not exist existingDirectory: An existing directory existingFile: An existing file existingSocket: An existing socket existingCharacter: An existing character device existingBlock: An existing block device gcePersistentDisk: label: Google Persistent Disk persistentDiskName: label: Persistent Disk Name placeholder: e.g. abc awsElasticBlockStore: label: Amazon EBS Disk volumeId: label: Volume ID placeholder: e.g. volume1 azureFile: label: Azure Filesystem shareName: label: Share Name placeholder: e.g. abc azureDisk: label: Azure Disk diskName: label: Disk Name placeholder: e.g. kubernetes-pvc diskURI: label: Disk URI placeholder: e.g. https://example.com/disk kind: label: Kind dedicated: Dedicated managed: Managed shared: Shared cachingMode: label: Caching Mode none: None readOnly: Read Only readWrite: Read Write filesystemType: label: Filesystem Type placeholder: e.g. ext4 readOnly: label: Read Only persistentVolumeClaim: accessModes: Access Modes capacity: Capacity storageClass: Storage Class useDefault: Use the default class volumes: Persistent Volumes volumeName: Persistent Volume Name source: label: Source options: new: Use a Storage Class to provision a new Persistent Volume existing: Use an existing Persistent Volume volumeClaim: label: Volume Claim storageClass: Storage Class requestStorage: Request Storage persistentVolume: Persistent Volume customize: label: Customize accessModes: readWriteOnce: Single Node Read-Write readOnlyMany: Many Nodes Read-Only readWriteMany: Many Nodes Read-Write status: label: Status prefs: title: Preferences theme: label: Theme light: Light auto: Auto dark: Dark autoDetail: Auto uses OS preference if available, or dark from {pm} to {am} landing: label: Login Landing Page vue: Cluster Explorer ember: Cluster Manager formatting: Formatting clusterToShow: label: Number of clusters to show in side menu value: |- {count, number} dateFormat: label: Date Format timeFormat: label: Time Format perPage: label: Table Rows per Page value: |- {count, number} keymap: label: YAML Editor Key Mapping sublime: 'Normal human' emacs: 'Emacs' vim: 'Vim' advanced: Advanced dev: label: Enable Developer Tools & Features hideDesc: label: Hide All Type Description Boxes helm: 'true': Include Prerelease Versions 'false': Show Releases Only label: Helm Charts experimental: Experimental onlyFromVentura_x64: This setting requires macOS 13.0 (Ventura) or later. onlyFromVentura_arm64: This setting requires macOS 13.3 (Ventura) or later. onlyWithVZ_x64: This setting requires using the VZ emulation mode. VZ is only available on macOS 13.0 (Ventura) or later. onlyWithVZ_arm64: This setting requires using the VZ emulation mode. VZ is only available on macOS 13.3 (Ventura) or later. principal: loading: Loading… error: Unable to fetch principal info name: Name loginName: Username type: Type probe: checkInterval: label: Check Interval placeholder: 'Default: 10' command: label: Command to run placeholder: e.g. cat /tmp/health failureThreshold: label: Failure Threshold placeholder: 'Default: 3' httpGet: headers: label: Request Headers path: label: Request Path placeholder: e.g. /healthz port: label: Check Port placeholder: e.g. 80 placeholderDuex: e.g. 25 initialDelay: label: Initial Delay placeholder: 'Default: 0' successThreshold: label: Success Threshold placeholder: 'Default: 1' timeout: label: Timeout placeholder: 'Default: 3' type: label: Type placeholder: Select a check type project: containerDefaultResourceLimit: Container Default Resource Limit resourceQuotas: Resource Quotas projectNamespaces: createNamespace: Create Namespace createProject: Create Project label: Projects/Namespaces noNamespaces: There are no namespaces defined. prometheusRule: alertingRules: addLabel: Add Alert annotations: description: input: Description Annotation Value label: Description label: Annotations message: input: Message Annotation Value label: Message runbook: input: Runbook URL Annotation Value label: Runbook URL summary: input: Summary Annotation Value label: Summary bannerText: 'When firing alerts, the annotations and labels will be passed to the configured AlertManagers to allow them to construct the notification that will be sent to any configured Receivers.' for: label: Wait to fire for placeholder: '60' label: Alerting Rules labels: label: Labels severity: choices: critical: critical label: Severity Label Value none: none warning: warning label: Severity name: Alert Name removeAlert: Remove Alert groups: add: Add Rule Group groupRowLabel: Rule Group {index} groupInterval: label: Override Group Interval placeholder: '60' label: Rule Groups name: Group Name none: Please add at least one rule group that contains at least one alerting or one recording rule. removeGroup: Remove Group responseStrategy: label: Partial Response Strategy promQL: label: PromQL Expression recordingRules: addLabel: Add Record label: Recording Rules labels: Labels name: Time Series Name removeRecord: Remove Record promptRemove: andOthers: |- {count, plural, =0 {.} =1 { and one other.} other { and {count} others.} } attemptingToRemove: "You are attempting to delete the {type}" protip: "Tip: Hold the {alternateLabel} key while clicking delete to bypass this confirmation" confirmName: "Enter {nameToMatch} below to confirm:" promptRestore: title: Restore Snapshot promptSaveAsRKETemplate: title: Create RKE Template from {cluster} name: Cluster Template Name description: Create a new RKE cluster template and initial revision from the current cluster configuration. warning: This will modify the cluster, setting it up to use the newly created cluster template and revision. rancherAlertingDrivers: msTeams: Enable Microsoft Teams sms: Enable SMS selectOne: You must select at least one of the options below. rbac: roleBinding: noData: There are no members associated with this resource. user: label: User role: label: Role add: Add Member displayRole: fleetworkspace-admin: Admin fleetworkspace-member: Member fleetworkspace-readonly: Read-Only members: label: Members roletemplate: label: Roles newUserDefault: no: No tooltip: This does not affect any bindings to the role that already exist. locked: label: Locked yes: 'Yes: New bindings are not allowed to use this role' no: No tabs: grantResources: label: Grant Resources tableHeaders: verbs: Verbs resources: Resource nonResourceUrls: Non-Resource URLs apiGroups: API Groups subtypes: GLOBAL: createButton: Create Global Role label: Global yes: "Yes: Default role for new users" defaultLabel: New User Default CLUSTER: createButton: Create Cluster Role label: Cluster yes: "Yes: Default role for new cluster creation" defaultLabel: Cluster Creator Default NAMESPACE: createButton: Create Project/Namespaces Role label: Project/Namespaces yes: "Yes: Default role for new project creation" defaultLabel: Project Creator Default RBAC_ROLE: label: Role RBAC_CLUSTER_ROLE: label: Cluster Role noContext: label: No Context globalRoles: types: global: label: Global Permissions description: |- Controls what access the {isUser, select, true {user} false {group}} has to administer the overall {appName} installation. custom: label: Custom description: 'Roles not created by {vendor}.' builtin: label: Built-in description: Additional roles to define more fine-grain permissions model. unknownRole: description: No description provided assignOnlyRole: This role is already assigned role: admin: label: Administrator description: Administrators have full control over the entire installation and all resources in all clusters. restricted-admin: label: Restricted Administrator description: Restricted Admins have full control over all resources in all downstream clusters but no access to the local cluster. user: label: Standard User description: Standard Users can create new clusters and manage clusters and projects they have been granted access to. user-base: label: User-Base description: User-Base users have login-access only. clusters-create: label: Create new Clusters description: Allows the user to create new clusters and become the owner of them. Standard Users have this permission by default. clustertemplates-create: label: Create new RKE Cluster Templates description: Allows the user to create new RKE cluster templates and become the owner of them. authn-manage: label: Configure Authentication description: Allows the user to enable, configure, and disable all Authentication provider settings. catalogs-manage: label: Configure Catalogs description: Allows the user to add, edit, and remove Catalogs. clusters-manage: label: Manage all Clusters description: Allows the user to manage all clusters, including ones they are not a member of. clusterscans-manage: label: Manage CIS Cluster Scans description: Allows the user to launch new and manage CIS cluster scans. kontainerdrivers-manage: label: Create new Cluster Drivers description: Allows the user to create new cluster drivers and become the owner of them. features-manage: label: Configure Feature Flags description: Allows the user to enable and disable custom features via feature flag settings. nodedrivers-manage: label: Configure Node Drivers description: Allows the user to enable, configure, and remove all Node Driver settings. nodetemplates-manage: label: Manage Node Templates description: Allows the user to define, edit, and remove Node Templates. podsecuritypolicytemplates-manage: label: Manage Pod Security Policies (PSPs) description: Allows the user to define, edit, and remove PSPs. roles-manage: label: Manage Roles description: Allows the user to define, edit, and remove Role definitions. settings-manage: label: Manage Settings description: 'Allows the user to manage {vendor} Settings.' users-manage: label: Manage Users description: Allows the user to create, remove, and set passwords for all Users. catalogs-use: label: Use Catalogs description: Allows the user to see and deploy Templates from the Catalog. Standard Users have this permission by default. nodetemplates-use: label: Use Node Templates description: Allows the user to deploy new Nodes using any existing Node Templates. view-rancher-metrics: label: 'View {vendor} Metrics' description: Allows the user to view Metrics through the API. base: label: Login Access resourceDetail: detailTop: annotations: Annotations created: Created deleted: Deleted description: Description labels: Labels ownerReferences: |- {count, plural, =1 {Owner} other {Owners}} hideAnnotations: |- {annotations, plural, =1 {Hide 1 annotation} other {Hide {annotations} annotations}} showAnnotations: |- {annotations, plural, =1 {Show 1 annotation} other {Show {annotations} annotations}} name: Name header: clone: "Clone from {subtype} {name}" create: Create {subtype} import: Import {subtype} edit: "{subtype} {name}" stage: "Stage from {subtype} {name}" view: "{subtype} {name}" masthead: age: Age defaultBannerMessage: error: This resource is currently in an error state, but there isn't a detailed message available. transitioning: This resource is currently in a transitioning state, but there isn't a detailed message available. sensitive: hide: Hide Sensitive Values show: Show Sensitive Values namespace: Namespace workspace: Workspace project: Project detail: Detail config: Config yaml: YAML managedWarning: |- This {type} is managed by {hasName, select, no {a {managedBy} app} yes {the {managedBy} app {appName}}}; changes made here will likely be overwritten the next time the app is changed. resourceList: head: create: Create createFromYaml: Create from YAML createResource: "Create {resourceName}" resourceTable: groupBy: none: Flat List namespace: Group by Namespace project: Group by Project groupLabel: cluster: "Cluster: {name}" namespace: "Namespace: {name}" machinePool: "Machine Pool: {name}" notInANamespace: Not Namespaced notInAProject: Not in a Project project: "Project: {name}" notInAWorkspace: Not in a Workspace workspace: "Workspace: {name}" resourceTabs: conditions: tab: Conditions events: tab: Recent Events related: tab: Related Resources from: Referred To By to: Refers To resourceYaml: errors: namespaceRequired: This resource is namespaced, so a namespace must be provided. buttons: continue: Continue Editing edit: Edit YAML diff: Show Diff unified: Unified split: Split rioConfig: configure: description: Description helpText: listItem1: The application deployment engine for Kubernetes. listItem2: "Rio makes it faster and easier for DevOps to build, test, deploy, scale and version stateless applications" requirements: header: Requirements helpText: listItem1: 1 CPU Core listItem2: 2 GiB of Memory header: Rio yaml: buttonText: Customize secret: authentication: Authentication certificate: certificate: Certificate cn: Domain Name expires: Expires issuer: Issuer plusMore: "+ {n} more" privateKey: Private Key data: Data registry: address: Registry domainName: Registry Domain Name password: Password username: Username basic: password: Password username: Username ssh: keys: Keys public: Public Key private: Private Key serviceAcct: ca: CA Certificate token: Token type: Type types: 'opaque': 'Opaque' 'kubernetes.io/service-account-token': 'Svc Acct Token' 'kubernetes.io/dockercfg': 'Registry' 'kubernetes.io/dockerconfigjson': 'Registry' 'kubernetes.io/basic-auth': 'HTTP Basic Auth' 'kubernetes.io/ssh-auth': 'SSH Key' 'kubernetes.io/tls': 'TLS Certificate' 'bootstrap.kubernetes.io/token': 'Bootstrap Token' 'istio.io/key-and-cert': 'Istio Certificate' 'helm.sh/release.v1': 'Helm Release' 'fleet.cattle.io/cluster-registration-values': 'Fleet Cluster' 'provisioning.cattle.io/cloud-credential': 'Cloud Credential' initials: 'opaque': 'O' 'kubernetes.io/service-account-token': 'SAT' 'kubernetes.io/dockercfg': 'R' 'kubernetes.io/dockerconfigjson': 'R' 'kubernetes.io/basic-auth': 'HTTP' 'kubernetes.io/ssh-auth': 'SSH' 'kubernetes.io/tls': 'TLS' 'bootstrap.kubernetes.io/token': 'Boot' 'istio.io/key-and-cert': 'Ist' 'helm.sh/release.v1': 'Helm' 'fleet.cattle.io/cluster-registration-values': 'F' 'provisioning.cattle.io/cloud-credential': 'CC' relatedWorkloads: Related Workloads selectOrCreateAuthSecret: label: Authentication options: none: None basic: HTTP Basic Auth ssh: SSH Key aws: AWS/S3 custom: Secret Name aws: accessKey: Access Key secretKey: Secret Key ssh: publicKey: Public Key privateKey: Private Key basic: username: Username password: Password namespaceGroup: "Namespace: {name}" chooseExisting: "Choose an existing secret:" createSsh: Create a SSH Key Secret createBasic: Create a HTTP Basic Auth Secret createAws: Create an AWS/S3 Auth Secret servicePorts: header: label: Port Rules rules: listening: label: Listening Port placeholder: e.g. 8080 name: label: Port Name placeholder: e.g. myport node: label: Node Port placeholder: e.g. 30000 protocol: label: Protocol target: label: Target Port placeholder: e.g. 80 or http serviceTypes: clusterip: Cluster IP externalname: External Name headless: Headless loadbalancer: Load Balancer nodeport: Node Port servicesPage: anyNode: Any Node labelsAnnotations: label: Labels & Annotations affinity: actionLabels: clientIp: ClientIP none: There is no session affinity configured. helpText: Map connections to a consistent target based on their source IP. label: Session Affinity timeout: label: Session Sticky Time placeholder: e.g. 10800 externalName: define: External Name helpText: "External Name is intended to specify a canonical DNS name. This is a required field. To hardcode an IP address, use a Headless service." label: External Name placeholder: e.g. my.database.example.com input: label: DNS Name ips: define: Service Ports clusterIpHelpText: The Cluster IP address must be within the CIDR range configured for the API server. external: label: External IPs placeholder: e.g. 1.1.1.1 protip: List of IP addresses for which nodes in the cluster will also accept traffic for this service. input: label: Cluster IP placeholder: e.g. 10.43.xxx.xxx label: IP Addresses pods: label: Pods ports: label: Ports selectors: helpText: "" label: Selectors matchingPods: matchesSome: |- {matched, plural, =0 {Matches 0 of {total, number} pods. If no selector is created, manual endpoints must be made.} =1 {Matches 1 of {total, number} pods: "{sample}"} other {Matches {matched, number} of {total, number} existing pods, including "{sample}"} } serviceTypes: clusterIp: abbrv: IP description: Exposes the service on a cluster-internal IP. Choosing this value makes the service only reachable from within the cluster. This is the default type. label: Cluster IP externalName: abbrv: EN description: "Maps the service to the contents of the `externalName` field (e.g. foo.bar.example.com), by returning a CNAME record with its value. No proxying of any kind is set up." label: External Name headless: abbrv: H description: Neither a cluster IP or load balancer is defined. These are used to interface with other service discovery mechanisms outside of Kubernetes implementation. A cluster IP is not allocated and kube-proxy does not handle these services. label: Headless loadBalancer: abbrv: LB description: Exposes the service externally using a cloud provider's load balancer. label: Load Balancer nodePort: abbrv: NP description: "Exposes the service on each node's IP at a static port (the `NodePort`). You'll be able to contact this type of service, from outside the cluster, by requesting `:`." label: Node Port typeOpts: label: Service Type setup: welcome: Welcome to {vendor}! setPassword: The first order of business is to set a strong password for the default admin user. We suggest using this random one generated just for you, but enter your own if you like. newPassword: New Password confirmPassword: Confirm New Password useRandom: Use a randomly generated password useManual: Set a specific password to use defaultPasswordError: It looks like this is your first time visiting the Rancher UI, but the local admin account password is already set to something unique. Log in with that account below to continue the setup process. telemetry: label: Allow collection of anonymous statistics to help us improve Rancher tip: 'Rancher Labs would like to collect a bit of anonymized information about the configuration of your installation to help make Rio better. Your data will not be shared with anyone else, and no information about what specific resources or endpoints you are deploying is included. Once enabled you can view exactly what data will be sent at /v1-telemetry. More Info' eula: I agree to the terms and conditions for using Rancher. serverUrl: label: Server URL tip: What URL should be used for this Rancher installation? All the nodes in your clusters will need to be able to reach this. You can skip setting this for now, and update it later in General Settings>Advanced Settings. skip: Skip sortableTable: bulkActions: collapsed: label: Actions actionAvailability: selected: "{actionable} selected" some: "Available for {actionable} of the {total} selected" noData: There are no rows which match your search query. noRows: There are no rows to show. noActions: No actions available paging: generic: |- {pages, plural, =0 {No Items} =1 {{count} {count, plural, =1 {Item} other {Items}}} other {{from} - {to} of {count} Items}} resource: |- {pages, plural, =0 {No {pluralLabel}} =1 {{count} {count, plural, =1 {{singularLabel}} other {{pluralLabel}}}} other {{from} - {to} of {count} {pluralLabel}}} search: Filter in: in addFilter: Add Filter filterFor: Filter for... selectCol: Select a column resetFilters: Reset add: Add tableHeader: noFilter: This column cannot be filtered by groupBy: Group by show: Show storageClass: actions: setAsDefault: Set as Default resetDefault: Reset Default parameters: label: Parameters customize: label: Customize reclaimPolicy: label: Reclaim Policy delete: Delete volumes and underlying device when volume claim is deleted retain: Retain the volume for manual cleanup allowVolumeExpansion: label: Allow Volume Expansion enabled: Enabled disabled: Disabled volumeBindingMode: label: Volume Binding Mode now: Bind and provision a persistent volume once the PersistentVolumeClaim is created later: Bind and provision a persistent volume once a Pod using the PersistentVolumeClaim is created mountOptions: label: Mount Options addlabel: Add Option aws-ebs: title: Amazon EBS Disk volumeType: label: Volume Type gp2: GP2 - General Purpose SSD io1: IO1 - Provisioned IOPS SSD st1: ST1 - Throughput-Optimized HDD sc1: SC1 - Cold-Storage HDD provisionedIops: label: Provisioned IOPS suffix: per second, per GB filesystemType: label: Filesystem Type placeholder: e.g. ext4 availabilityZone: label: Availability Zone automatic: 'Automatic: Zones the cluster has a node in' manual: 'Manual: Choose specific zones' placeholder: us-east-1d, us-east-1c encryption: label: Encryption enabled: Enabled disabled: Disabled keyId: label: KMS Key ID for Encryption automatic: 'Automatic: Generate a key' manual: 'Manual: Use a specific key (full ARN)' azure-disk: title: Azure Disk storageAccountType: label: Storage Account Type placeholder: e.g. Standard_LRS kind: label: Kind shared: Shared (unmanaged disk) dedicated: Dedicated (unmanaged disk) managed: Managed azure-file: title: Azure File skuName: label: Sku Name placeholder: e.g. Standard_LRS location: label: Location placeholder: e.g. eastus storageAccount: label: Storage Account placeholder: e.g. azure_storage_account_name gce-pd: title: Google Persistent Disk volumeType: label: Volume Type standard: Standard ssd: SSD filesystemType: label: Filesystem Type placeholder: e.g. ext4 availabilityZone: label: Availability Zone automatic: 'Automatic: Zones the cluster has a node in' manual: 'Manual: Choose specific zones' placeholder: us-east-1d, us-east-1c replicationType: label: Replication Type zonal: Zonal regional: Regional longhorn: title: Longhorn addLabel: Add Parameter vsphere-volume: title: VMWare vSphere Volume diskFormat: label: Disk Format thin: Thin zeroedthick: Zeroed Thick eagerzeroedthick: Eager Zeroed Thick storagePolicyName: label: Storage Policy Name placeholder: e.g. gold datastore: label: Datastore placeholder: e.g. VSANDatastore hostFailuresToTolerate: label: Host Failures To Tolerate placeholder: e.g. 2 cacheReservation: label: Cache Reservation placeholder: e.g. 20 filesystemType: label: Filesystem Type placeholder: e.g. ext3 custom: addLabel: Add Parameter glusterfs: title: Gluster Volume (Unsupported) restUrl: label: REST URL placeholder: e.g. http://127.0.0.1:8081 restUser: label: REST User placeholder: e.g. admin restUserKey: label: REST User Key placeholder: e.g. password secretNamespace: label: Secret Namespace placeholder: e.g. default secretName: label: Secret Name placeholder: e.g. heketi-secret clusterId: label: Cluster ID placeholder: e.g. 630372ccdc720a92c681fb928f27b53f gidMin: label: GID MIN placeholder: e.g. 40000 gidMax: label: GID MAX placeholder: e.g. 50000 volumeType: label: Volume Type placeholder: "e.g. replicate:3" cinder: title: Openstack Cinder Volume (Unsupported) volumeType: label: Volume Type placeholder: e.g. fast availabilityZone: label: Availability Zone automatic: "Automatic: Zones the cluster has a node in" manual: label: "Manual: Choose specific zones" placeholder: e.g. nova rbd: title: Ceph RBD (Unsupported) monitors: label: Monitors placeholder: e.g. 10.16.153.105:6789 adminId: label: Admin ID placeholder: e.g. kube adminSecretNamespace: label: Admin Secret Namespace placeholder: e.g. kube-system adminSecret: label: Admin Secret placeholder: e.g. Secret pool: label: Pool placeholder: e.g. kube userId: label: User ID placeholder: e.g. kube userSecretNamespace: label: User Secret Namespace placeholder: e.g. default userSecretName: label: User Secret Name placeholder: e.g. ceph-secret-user filesystemType: label: Filesystem Type placeholder: e.g. ext4 imageFormat: label: Image Format placeholder: e.g. 2 imageFeatures: label: Image Features placeholder: e.g. layering quobyte: title: Quobyte Volume (Unsupported) quobyteApiServer: label: Quobyte API Server placeholder: "e.g. http://138.68.74.142:7860" registry: label: Registry placeholder: e.g. 138.68.74.142:7861 adminSecretNamespace: label: Admin Secret Namespace placeholder: e.g. kube-system adminSecretName: label: Admin Secret Name placeholder: e.g. quobyte-admin-secret user: label: User placeholder: e.g. root group: label: Group placeholder: e.g. root quobyteConfig: label: Quobyte Config placeholder: e.g. BASE quobyteTenant: label: Quobyte Tenant placeholder: e.g. DEFAULT portworx-volume: title: Portworx Volume (Unsupported) filesystem: label: Filesystem placeholder: e.g. ext4 blockSize: label: Block Size placeholder: e.g. 32 repl: label: Repl placeholder: e.g.1; 0 for entire device ioPriority: label: I/O Priority placeholder: e.g. low snapshotsInterval: label: Snapshots Interval placeholder: e.g. 70 aggregationLevel: label: Aggregation Level placeholder: e.g. 0 ephemeral: label: Ephemeral placeholder: e.g. true scaleio: title: ScaleIO Volume (Unsupported) gateway: label: Gateway placeholder: e.g. https://192.168.99.200:443/api system: label: System placeholder: e.g. scaleio protectionDomain: label: Protection Domain placeholder: e.g. pd0 storagePool: label: Storage Pool placeholder: e.g. sp1 storageMode: label: StorageMode thin: Thin Provisioned thick: Thick Provisioned secretRef: label: Secret Ref placeholder: e.g. sio-secret readOnly: label: Read Only filesystemType: label: Filesystem Type placeholder: e.g. xfs storageos: title: StorageOS (Unsupported) pool: label: Pool placeholder: e.g. default description: label: Description placeholder: e.g. Kubernetes volume filesystemType: label: Filesystem Type placeholder: e.g. ext4 adminSecretNamespace: label: Admin Secret Namespace placeholder: e.g. default adminSecretName: label: Admin Secret Name placeholder: e.g. storageos-secret no-provisioner: title: Local Storage (Unsupported) tableHeaders: accessKey: Access Key address: Address age: Age apiGroup: API Groups authRoles: globalDefault: New User Default clusterDefault: Cluster Creator Default projectDefault: Project Creator Default branch: Branch builtIn: Built In bundlesReady: Bundles bundleDeploymentsReady: Deployments builtin: Built-In chart: Chart clusterCreatorDefault: Cluster Creator Default clusterFlow: Cluster Flow clusterOutput: Cluster Output clusters: Clusters clustersReady: Clusters Ready clusterGroups: Cluster Groups commit: Commit condition: Condition customVerbs: Custom Verbs description: Description expires: Expires providers: Providers cpu: CPU date: Date default: Default destination: Target download: Download effect: Effect endpoints: Endpoints flow: Flow gitRepos: Git Repos host: |- {count, plural, one { Host } other { Hosts } } image: Image imageSize: Size ingressDefaultBackend: Default ingressTarget: Target internalExternalIp: External/Internal IP jobs: Jobs key: Key keys: Data lastUpdated: Last Updated lastSeen: Last Seen loggingOutputProviders: Provider machines: Machines manual: Manual matches: Matches maxKubernetesVersion: Max Kubernetes Version message: Message minKubernetesVersion: Min Kubernetes Version memory: Memory name: Name nameDisplay: Display Name nameUnlinked: Name namespace: Namespace namespaceName: Name namespaceNameUnlinked: Name node: Node nodeName: Name nodesReady: Nodes Ready nodePort: Node Port object: Object output: Output p95: 95%tile persistentVolumeSource: Source podImages: Image pods: Pods port: Port protocol: Protocol provider: Provider publicPorts: Public Ports ram: RAM rbac: create: Create delete: Delete get: Get list: List patch: Patch update: Update watch: Watch ready: Ready reason: Reason repo: Repo reposReady: Repos Ready replicas: Replicas reqRate: Req Rate resource: Resource resources: Resources restarts: Restarts rioImage: Image role: Role roles: Roles scale: Scale scope: Scope selector: Selector simpleName: Name simpleScale: Scale simpleType: Type size: Size started: Started state: State status: Status storage_class_provisioner: Provisioner subject: Subject subType: Kind success: Success summary: Summary target: Target targetKind: Target Type targetPort: Target type: Type updated: Updated upgrade: Upgradable url: URL userDisplayName: Display Name userId: ID userStatus: Status username: Local Username value: Value version: Version weight: Weight target: router: label: Router placeholder: Select a router service: label: Service placeholder: Select a service title: Target version: label: Version placeholder: Select a version user: detail: username: Username globalPermissions: label: Global Permissions description: Access to manage resources that affect the entire installation adminMessage: This user is an administrator and has all permissions tableHeaders: permission: Permission clusterRoles: label: Cluster Roles description: Roles granted to this user for individual clusters tableHeaders: cluster: Cluster projectRoles: label: Project Roles description: Roles granted to this user for individual projects tableHeaders: project: Project generic: tableHeaders: role: Role granted: Granted edit: credentials: label: Credentials username: label: Username placeholder: e.g. jsmith exists: 'Username is already in use. Please choose a new username' displayName: label: Display Name placeholder: e.g. John Smith userDescription: label: Description placeholder: e.g. This account is for John Smith list: errorRefreshingGroupMemberships: Error refreshing group memberships validation: arrayLength: between: '"{key}" should contain between {min} and {max} {max, plural, =1 {item} other {items}}' exactly: '"{key}" should contain {count, plural, =1 {# item} other {# items}}' max: '"{key}" should contain at most {count} {count, plural, =1 {item} other {items}}' min: '"{key}" should contain at least {count} {count, plural, =1 {item} other {items}}' boolean: '"{key}" must be a boolean value.' chars: '"{key}" contains {count, plural, =1 {an invalid character} other {# invalid characters}}: {chars}' custom: missing: 'No validator exists for { validatorName }! Does the validator exist in custom-validators? Is the name spelled correctly?' dns: doubleHyphen: '"{key}" Cannot contain two or more consecutive hyphens' hostname: empty: '"{key}" must be at least one character' emptyLabel: '"{key}" cannot contain two consecutive dots' endDot: '"{key}" cannot end with a dot' endHyphen: '"{key}" cannot end with a hyphen' startDot: '"{key}" cannot start with a dot' startHyphen: '"{key}" cannot start with a hyphen' startNumber: '"{key}" cannot start with a number' tooLong: '"{key}" cannot be longer than {max} characters' tooLongLabel: '"{key}" cannot contain a section longer than {max} characters' label: emptyLabel: '"{key}" cannot be empty' endHyphen: '"{key}" cannot end with a hyphen' startHyphen: '"{key}" cannot start with a hyphen' startNumber: '"{key}" cannot start with a number' tooLongLabel: '"{key}" cannot be more than {max} characters' flowOutput: both: Requires "Output" or "Cluster Output" to be selected. global: Requires "Cluster Output" to be selected. output: logdna: apiKey: Required an "Api Key" to be set. invalidCron: Invalid cron schedule k8s: identifier: emptyLabel: '"{key}" cannot have an empty key' emptyPrefix: '"{key}" cannot have an empty prefix' endLetter: '"{key}" must end with a letter or number' startLetter: '"{key}" must start with a letter or number' tooLongKey: '"{key}" cannot have a key longer than {max} characters' tooLongPrefix: '"{key}" cannot have a prefix longer than {max} characters' noSchema: No schema found to validate noType: No type to validate number: between: '"{key}" should be between {min} and {max}' exactly: '"{key}" should be exactly {val}' max: '"{key}" should be at most {val}' min: '"{key}" should be at least {val}' podAffinity: affinityTitle: Pod Affinity antiAffinityTitle: Pod Anti-Affinity requiredDuringSchedulingIgnoredDuringExecution: required rules preferredDuringSchedulingIgnoredDuringExecution: preferred rules topologyKey: Rule [{index}] of {group} {rules} - Topology key is required. matchExpressions: operator: Rule [{index}] of {group} {rules} - operator must be one of 'In', 'NotIn', 'Exists', 'DoesNotExist' valueMustBeEmpty: Rule [{index}] of {group} {rules} - value must be empty if operator is 'Exists' or 'DoesNotExist' valuesMustBeDefined: Rule [{index}] of {group} {rules} - value must be defined if operator is 'In' or 'NotIn' port: A port must be a number between 1 and 65535. prometheusRule: groups: required: At least one rule group is required. singleAlert: A rule may contain alert rules or recording rules but not both. valid: name: 'Name is required for rule group {index}.' rule: alertName: 'Rule group {groupIndex} rule {ruleIndex} requires a Alert Name.' expr: 'Rule group {groupIndex} rule {ruleIndex} requires a PromQL Expression.' labels: 'Rule group {groupIndex} rule {ruleIndex} requires at least one label. Severity is recommended.' recordName: 'Rule group {groupIndex} rule {ruleIndex} requires a Time Series Name.' singleEntry: 'At least one alert rule or one recording rule is required in rule group {index}.' required: '"{key}" is required' requiredOrOverride: '"{key}" is required or must allow override' roleTemplate: roleTemplateRules: missingVerb: You must specify at least one verb for each resource grant missingResource: You must specify a Resource for each resource grant missingApiGroup: You must specify an API Group for each resource grant missingOneResource: You must specify at least one Resource, Non-Resource URL or API Group for each resource grant service: externalName: none: External Name is required on an ExternalName Service. ports: name: required: 'Port Rule [{position}] - Name is required.' nodePort: requriedInt: 'Port Rule [{position}] - Node Port must be integer values if included.' port: required: 'Port Rule [{position}] - Port is required.' requriedInt: 'Port Rule [{position}] - Port must be integer values if included.' targetPort: between: 'Port Rule [{position}] - Target Port must be between 1 and 65535' iana: 'Port Rule [{position}] - Target Port must be an IANA Service Name or Integer' ianaAt: 'Port Rule [{position}] - Target Port ' required: 'Port Rule [{position}] - Target Port is required' stringLength: between: '"{key}" should be between {min} and {max} {max, plural, =1 {character} other {characters}}' exactly: '"{key}" should be {count, plural, =1 {# character} other {# characters}}' max: '"{key}" should be at most {count} {count, plural, =1 {character} other {characters}}' min: '"{key}" should be at least {count} {count, plural, =1 {character} other {characters}}' targets: missingProjectId: A target must have a project selected. monitoring: route: match: At least one Match or Match Regex must be selected interval: '"{key}" must be of a format with digits followed by a unit i.e. 1h, 2m, 30s' wizard: previous: Previous finish: Finish next: Next step: "Step {number}" wm: connection: connected: Connected connecting: Connecting… disconnected: Disconnected error: Error containerLogs: clear: Clear containerName: "Container: {label}" download: Download follow: Follow noData: There are no log entries to show in the current range. noMatch: No lines match the current filter. previous: Use Previous Container range: all: Everything hours: |- {value, number} {value, plural, =1 {Hour} other {Hours} } label: Show the last lines: "{value, number} Lines" minutes: |- {value, number} {value, plural, =1 {Minute} other {Minutes} } search: Filter timestamps: Show Timestamps wrap: Wrap Lines containerShell: clear: Clear containerName: "Container: {label}" kubectlShell: title: "Kubectl: {name}" workload: container: command: addEnvVar: Add Variable args: Arguments as: as command: Command env: Environment Variables fromResource: key: label: Key placeholder: "e.g. metadata.labels['']" name: label: Variable Name placeholder: "e.g. FOO" prefix: Prefix source: label: Source placeholder: e.g. my-container secret: Secret configMap: ConfigMap containerName: Container Name type: Type value: label: Value placeholder: e.g. bar tty: TTY workingDir: WorkingDir stdin: Stdin containerName: Container Name healthCheck: checkInterval: Check Interval command: command: Command to run failureThreshold: Failure Threshold httpGet: headers: Request Headers path: Request Path port: Check Port initialDelay: Initial Delay livenessProbe: Liveness Check livenessTip: Containers will be restarted when this check is failing. Not recommended for most uses. noHealthCheck: "There is not a Readiness Check, Liveness Check or Startup Check configured." readinessProbe: Readiness Check readinessTip: Containers will be removed from service endpoints when this check is failing. Recommended. startupProbe: Startup Check startupTip: Containers will wait until this check succeeds before attempting other health checks. successThreshold: Success Threshold timeout: Timeout kind: none: None HTTP: HTTP request returns a successful status (200-399) HTTPS: HTTPS request returns a successful status tcp: TCP connection opens successfully exec: Command run inside the container exits with status 0 image: Container Image imagePullPolicy: Pull Policy imagePullSecrets: Pull Secrets init: Init Container name: Container Name noResourceLimits: There are no resource requirements configured. noPorts: There are no ports configured. ports: createService: Service Type noCreateService: Do not create a service containerPort: Private Container Port hostIP: Host IP hostPort: Public Host Port name: Name protocol: Protocol listeningPort: Listening Port removeContainer: Remove Container security: addCapabilities: Add Capabilities addGroupIDs: Add Group IDs allowPrivilegeEscalation: label: Privilege Escalation 'false': No 'true': "Yes: container can gain more privileges than its parent process" dropCapabilities: Drop Capabilities fsGroup: Filesystem Group hostIPC: Use Host IPC Namespace hostPID: Use Host PID Namespace privileged: label: Privileged 'false': No 'true': "Yes: container has full access to the host" readOnlyRootFilesystem: label: Read-Only Root Filesystem 'false': No 'true': "Yes: container has a read-only root filesystem" runAsGroup: Run as Group ID runAsNonRoot: label: Run as Non-Root 'false': No 'true': "Yes: container must run as a non-root user" runAsNonRootOptions: noOption: "No" yesOption: "Yes: containers must run as non-root-user" runAsUser: Run as User ID shareProcessNamespace: Share single process namespace supplementalGroups: Additional Group IDs sysctls: Sysctls sysctlsKey: Name standard: Standard Container titles: container: Container command: Command containers: Containers env: Environment Variables events: Events general: General healthCheck: Health Check image: Image networking: Networking networkSettings: Network Settings podAnnotations: Pod Annotations podLabels: Pod Labels metrics: Metrics podScheduling: Pod Scheduling nodeScheduling: Node Scheduling ports: Ports resources: Resources securityContext: Security Context status: Status volumeClaimTemplates: Volume Claim Templates upgrading: Scaling and Upgrade Policy cronSchedule: Schedule detail: pods: title: Pods detailTop: node: Node podIP: Pod IP podRestarts: Pod Restarts workload: Workload pods: Pods by State runs: Runs gaugeStates: active: Active transitioning: Transitioning warning: Warning error: Error succeeded: Successful running: Running failed: Failed hideTabs: 'Hide Advanced Options' job: activeDeadlineSeconds: label: Active Deadline tip: The duration that the job may be active before the system tries to terminate it. backoffLimit: label: Back Off Limit tip: The number of retries before marking this job failed. completions: label: Completions tip: The number of successfully finished pods the job should be run with. failedJobsHistoryLimit: label: Failed Job History Limit tip: The number of failed finished jobs to retain. parallelism: label: Parallelism tip: The maximum number of pods the job should run at any given time. startingDeadlineSeconds: label: Starting Deadline Seconds tip: The deadline in seconds for starting the job if it misses scheduled time successfulJobsHistoryLimit: label: Successful Job History Limit tip: The number of successful finished jobs to retain. suspend: Suspend metrics: pod: Pod Metrics metricsView: Metrics View networking: dnsPolicy: label: DNS Policy options: clusterFirst: Cluster First clusterFirstWithHostNet: Cluster First With Host Network default: Default none: None placeholder: Select a Policy... hostAliases: add: Add Alias keyLabel: IP Address keyPlaceholder: e.g. 1.1.1.1 label: Host Aliases tip: Additional /etc/hosts entries to be injected in the container. valueLabel: Hostname valuePlaceholder: "e.g. foo.com, bar.com" hostname: label: Hostname placeholder: e.g. web nameservers: add: Add Nameserver label: Nameservers placeholder: e.g. 1.1.1.1 networkMode: label: Network Mode options: hostNetwork: Host Network normal: Normal placeholder: Select a Mode... dns: DNS resolver: label: Resolver Options add: Add Option searches: add: Add Search Domain label: Search Domains placeholder: e.g. mycompany.com subdomain: label: Subdomain placeholder: e.g. web validation: containers: Containers containerImage: Container {name} - "Container Image" is required. replicas: Replicas showTabs: 'Show Advanced Options' scheduling: activeDeadlineSeconds: Pod Active Deadline activeDeadlineSecondsTip: The duration that the pod may be active before the system tries to mark it failed and kill associated containers. affinity: addNodeSelector: Add Node Selector anyNode: Run pods on any available node affinityTitle: Run pods on nodes with pods matching these selectors antiAffinityTitle: Run pods on nodes without pods matching these selectors affinityOption: Affinity antiAffinityOption: Anti-Affinity matchExpressions: addRule: Add Rule doesNotExist: Does Not Exist exists: Exists greaterThan: ">" in: = inNamespaces: "Pods in these namespaces:" key: Key lessThan: < namespaces: Namespaces notIn: ≠ operator: Operator value: Value weight: Weight noPodRules: There are no pod scheduling rules configured. nodeName: Node Name priority: Priority preferAny: "Prefer any of:" preferred: Preferred required: Required requireAny: "Require any of:" schedulingRules: Run pods on node(s) matching scheduling rules specificNode: Run pods on specific node(s) thisPodNamespace: This pod's namespace topologyKey: label: Topology Key placeholder: e.g. failure-domain.beta.kubernetes.io/zone type: Type priority: className: Priority Class Name priority: Priority terminationGracePeriodSeconds: Termination Grace Period terminationGracePeriodSecondsTip: The duration that the pod needs to terminate gracefully. titles: advanced: Advanced nodeScheduling: Node Scheduling nodeSelector: Nodes with these labels podScheduling: Pod Scheduling priority: Priority tab: Scheduling tolerations: Tolerations limits: Limits and Reservations tolerations: addToleration: Add Toleration effect: Effect effectOptions: all: All noExecute: NoExecute noSchedule: "NoSchedule," preferNoSchedule: PreferNoSchedule labelKey: Label Key operator: Operator operatorOptions: equal: = exists: Exists tolerationSeconds: Toleration Seconds value: Value serviceName: Service Name storage: subtypes: secret: Secret configMap: ConfigMap hostPath: Bind-Mount persistentVolumeClaim: Persistent Volume Claim createPVC: Create Persistent Volume Claim csi: CSI nfs: NFS awsElasticBlockStore: Amazon EBS Disk azureDisk: Azure Disk azureFile: Azure File gcePersistentDisk: Google Persistent Disk driver.longhorn.io: Longhorn vsphereVolume: VMWare vSphere Volume addClaim: Add Claim addMount: Add Mount addVolume: Add Volume certificate: Certificate csi: diskName: Disk Name diskURI: Disk URI cachingMode: label: Caching Mode options: none: None readOnly: Read Only readWrite: Read Write kind: label: Kind options: dedicated: Dedicated managed: Managed shared: Shared drivers: driver.longhorn.io: Longhorn fsType: Filesystem Type shareName: Share Name secretName: Secret Name volumeID: Volume ID partition: Partition pdName: Persistent Disk Name storagePolicyID: Storage Policy ID storagePolicyName: Storage Policy Name volumePath: Volume Path defaultMode: Default Mode driver: driver hostPath: label: The Path on the Node must be options: default: 'Anything: do not check the target path' directoryOrCreate: A directory, or create if it doesn't exist directory: An existing directory fileOrCreate: A file, or create if it doesn't exist file: An existing file socket: An existing socket charDevice: An existing character device blockDevice: An existing block device mountPoint: Mount Point nodePath: Path on Node optional: label: Optional 'no': 'No' 'yes': 'Yes' path: Path readOnly: Read Only server: Server subPath: Sub Path in Volume title: 'Storage' volumeName: Volume Name volumePath: Volume Path typeDescriptions: apps.daemonset: DaemonSets run exactly one pod on every eligible node. When new nodes are added to the cluster, DaemonSets automatically deploy to them. Recommended for system-wide or vertically-scalable workloads that never need more than one pod per node. apps.deployment: Deployments run a scalable number of replicas of a pod distributed among the eligible nodes. Changes are rolled out incrementally and can be rolled back to the previous revision when needed. Recommended for stateless & horizontally-scalable workloads. apps.statefulset: StatefulSets manage stateful applications and provide guarantees about the ordering and uniqueness of the pods created. Recommended for workloads with persistent storage or strict identity, quorum, or upgrade order requirements. batch.cronjob: CronJobs create Jobs, which then run Pods, on a repeating schedule. The schedule is expressed in standard Unix cron format, and uses the timezone of the Kubernetes control plane (typically UTC). batch.job: Jobs create one or more pods to reliably perform a one-time task by running a pod until it exits successfully. Failed pods are automatically replaced until the specified number of completed runs has been reached. Jobs can also run multiple pods in parallel or function as a batch work queue. upgrading: activeDeadlineSeconds: label: Pod Active Deadline tip: The duration the pod may be active before the system will try to mark it failed and kill associated containers. concurrencyPolicy: label: Concurrency options: allow: Allow CronJobs to run concurrently forbid: Skip next run if current run hasn't finished replace: Replace run if current run hasn't finished maxSurge: label: Max Surge tip: The maximum number of pods allowed beyond the desired scale at any given time. maxUnavailable: label: Max Unavailable tip: The maximum number of pods which can be unavailable at any given time. minReadySeconds: label: Minimum Ready tip: The minimum duration a pod should be ready without containers crashing for it to be considered available. podManagementPolicy: label: Pod Management Policy progressDeadlineSeconds: label: Progress Deadline tip: The minimum duration to wait for a deployment to progress before marking it failed. revisionHistoryLimit: label: Revision History Limit tip: The number of old ReplicaSets to retain for rollback. strategies: labels: delete: "On Delete: New pods are only created when old pods are manually deleted." recreate: "Recreate: Kill ALL pods, then start new pods." rollingUpdate: "Rolling Update: Create new pods, until max surge is reached, before deleting old pods. Don't stop more pods than max unavailable." terminationGracePeriodSeconds: label: Termination Grace Period tip: The duration the pod needs to terminate successfully. title: Upgrading ############################## # Model Properties ############################## model: account: kind: admin: Admin agent: Agent project: Environment registeredAgent: Registered Agent service: Service user: User "catalog.cattle.io.app": firstDeployed: First Deployed lastDeployed: Last Deployed authConfig: description: ldap: LDAP saml: SAML oauth: OAuth oidc: OIDC name: keycloak: Keycloak (SAML) keycloakoidc: Keycloak (OIDC) provider: system: System local: Local multiple: Multiple activedirectory: ActiveDirectory azuread: AzureAD github: GitHub keycloak: Keycloak ldap: LDAP openldap: OpenLDAP shibboleth: Shibboleth ping: Ping Identity adfs: ADFS okta: Okta freeipa: FreeIPA googleoauth: Google oidc: OIDC keycloakoidc: Keycloak cluster: name: Cluster Name ingress: displayKind: L7 Ingress machine: role: controlPlane: Control Plane etcd: etcd worker: Worker openldapconfig: domain: help: Only users below this base will be used. label: User Search Base placeholder: "e.g. ou=Users,dc=mycompany,dc=com" server: label: Hostname or IP Address serviceAccountPassword: label: Service Account Password serviceAccountUsername: label: Service Account Username projectMember: role: member: Member owner: Owner readonly: Read-Only restricted: Restricted service: displayKind: generic: Service loadBalancer: L4 Balancer typeDescription: # Map of # type: Description to be shown on the top of list view describing the type. # Should fit on one line. # If you link to anything external, it MUST have # target="_blank" rel="noopener noreferrer nofollow" cis.cattle.io.clusterscanbenchmark: A benchmark version is the name of benchmark to run using kube-bench as well as the valid configuration parameters for that benchmark. cis.cattle.io.clusterscanprofile: A profile is the configuration for the CIS scan, which is the benchmark versions to use and any specific tests to skip in that benchmark. cis.cattle.io.clusterscan: A scan is created to trigger a CIS scan on the cluster based on the defined profile. A report is created after the scan is completed. cis.cattle.io.clusterscanreport: A report is the result of a CIS scan of the cluster. management.cattle.io.feature: Feature Flags allow certain {vendor} features to be toggled on and off. Features that are off by default should be considered experimental functionality. resources.cattle.io.backup: A backup is created to perform one-time backups or schedule recurring backups based on a ResourceSet. resources.cattle.io.restore: A restore is created to trigger a restore to the cluster based on a backup file. resources.cattle.io.resourceset: A resource set defines which CRDs and resources to store in the backup. monitoring.coreos.com.servicemonitor: A service monitor defines the group of services and the endpoints that Prometheus will scrape for metrics. This is the most common way to define metrics collection. monitoring.coreos.com.podmonitor: A pod monitor defines the group of pods that Prometheus will scrape for metrics. The common way is to use service monitors, but pod monitors allow you to handle any situation where a service monitor wouldn't work. monitoring.coreos.com.prometheusrule: A Prometheus Rule resource defines both recording and/or alert rules. A recording rule can pre-compute values and save the results. Alerting rules allow you to define conditions on when to send notifications to AlertManager. monitoring.coreos.com.prometheus: A Prometheus server is a Prometheus deployment whose scrape configuration and rules are determined by selected ServiceMonitors, PodMonitors, and PrometheusRules and whose alerts will be sent to all selected Alertmanagers with the custom resource's configuration. monitoring.coreos.com.alertmanager: An alert manager is deployment whose configuration will be specified by a secret in the same namespace, which determines which alerts should go to which receiver. catalog.cattle.io.clusterrepo: 'A chart repository is a Helm repository or {vendor} git based application catalog. It provides the list of available charts in the cluster.' catalog.cattle.io.operation: An operation is the list of recent Helm operations that have been applied to the cluster. catalog.cattle.io.app: An installed application is a Helm 3 chart that was installed either via our charts or through the Helm CLI. logging.banzaicloud.io.clusterflow: Logs from the cluster will be collected and logged to the selected Cluster Output. logging.banzaicloud.io.clusteroutput: A cluster output defines which logging providers that logs can be sent to and is only effective when deployed in the namespace that the logging operator is in. logging.banzaicloud.io.flow: A flow defines which logs to collect and filter as well as which output to send the logs. The flow is a namespaced resource, which means logs will only be collected from the namespace that the flow is deployed in. logging.banzaicloud.io.output: An output defines which logging providers that logs can be sent to. The output needs to be in the same namespace as the flow that is using it. group.principal: Assigning global roles to a group only works with external auth providers that support groups. Local authorization does not support groups. typeLabel: management.cattle.io.token: |- {count, plural, one { API Key } other { API Keys } } cis.cattle.io.clusterscan: |- {count, plural, one { Scan } other { Scans } } cis.cattle.io.clusterscanprofile: |- {count, plural, one { Profile } other { Profiles } } cis.cattle.io.clusterscanbenchmark: |- {count, plural, one { Benchmark Version } other { Benchmark Versions } } catalog.cattle.io.operation: |- {count, plural, one { Recent Operation } other { Recent Operations } } catalog.cattle.io.app: |- {count, plural, one { Installed App } other { Installed Apps } } catalog.cattle.io.clusterrepo: |- {count, plural, one { Chart Repository } other { Chart Repositories } } catalog.cattle.io.repo: |- {count, plural, one { Namespaced Repo } other { Namespaced Repos } } chartInstallAction: |- {count, plural, one { App } other { Apps } } chartUpgradeAction: |- {count, plural, one { App } other { Apps } } endpoints: |- {count, plural, one { Endpoint } other { Endpoints } } fleet.cattle.io.cluster: |- {count, plural, =1 { Cluster } other {Clusters } } fleet.cattle.io.clustergroup: |- {count, plural, one { Cluster Group } other {Cluster Groups } } management.cattle.io.clusterroletemplatebinding: |- {count, plural, one { Cluster Member } other { Cluster Members } } fleet.cattle.io.gitrepo: |- {count, plural, one { Git Repo } other {Git Repos } } management.cattle.io.authconfig: |- {count, plural, one { Authentication Provider } other { Authentication Providers } } management.cattle.io.feature: |- {count, plural, one { Feature Flag } other { Feature Flags } } management.cattle.io.setting: |- {count, plural, one { Advanced Setting } other { Advanced Settings } } management.cattle.io.fleetworkspace: |- {count, plural, one { Workspace } other { Workspaces } } # pruh-mee-thee-eyes https://www.prometheus.io/docs/introduction/faq/#what-is-the-plural-of-prometheus monitoring.coreos.com.prometheus: |- {count, plural, one { Prometheus } other { Prometheis } } monitoring.coreos.com.servicemonitor: |- {count, plural, one { Service Monitor } other { Service Monitors } } monitoring.coreos.com.alertmanager: |- {count, plural, one { Alert Manager } other { Alert Managers } } monitoring.coreos.com.podmonitor: |- {count, plural, one { Pod Monitor } other { Pod Monitors } } monitoring.coreos.com.prometheusrule: |- {count, plural, one { Prometheus Rule } other { Prometheus Rules } } monitoring.coreos.com.thanosruler: |- {count, plural, one { Thanos Rule } other { Thanos Rules } } monitoring.coreos.com.receiver: |- {count, plural, one { Receiver } other { Receivers } } monitoring.coreos.com.route: |- {count, plural, one { Route } other { Routes } } 'management.cattle.io.cluster': |- {count, plural, one { Mgmt Cluster } other { Mgmt Clusters } } 'cluster.x-k8s.io.cluster': |- {count, plural, one { CAPI Cluster } other { CAPI Clusters } } 'provisioning.cattle.io.cluster': |- {count, plural, one { Cluster } other { Clusters } } 'management.cattle.io.user': |- {count, plural, one { User } other { Users } } namespace: |- {count, plural, one { Namespace } other { Namespaces } } group.principal: |- {count, plural, one { Group } other { Groups } } token: |- {count, plural, one { API Key } other { API Keys } } action: clone: Clone disable: Disable download: Download YAML edit: Edit Config editYaml: Edit YAML enable: Enable openLogs: View Logs refresh: Refresh remove: Delete view: View Config viewInApi: View in API viewYaml: View YAML activate: Activate deactivate: Deactivate show: Show hide: Hide copy: Copy unassign: 'Unassign' uninstall: Uninstall unit: sec: secs min: mins hour: |- {count, plural, one { hour } other { hours } } day: |- {count, plural, one { day } other { days } } workloadPorts: addPort: Add Port remove: Remove addHost: Add Host podAffinity: addLabel: Add Pod Selector keyValue: keyPlaceholder: e.g. foo valuePlaceholder: e.g. bar ############################## ### Advanced Settings ############################## advancedSettings: label: Advanced Settings subtext: Typical users will not need to change these. Proceed with caution, incorrect values can break your {appName} installation. Settings which have been customized from default settings are tagged 'Modified'. show: Show hide: Hide none: None edit: label: Edit Setting changeSetting: "Change Setting:" trueOption: "True" falseOption: "False" value: Value useDefault: Copy the default value invalidJSON: Invalid JSON - please check and correct your input before saving descriptions: 'cacerts': "CA Certificates needed to verify the server's certificate." 'cluster-defaults': 'Override RKE Defaults when creating new clusters.' 'engine-install-url': 'Default Docker engine installation URL (for most node drivers).' 'engine-iso-url': 'Default OS installation URL (for vSphere driver).' 'engine-newest-version': 'The newest supported version of Docker at the time of this release. A Docker version that does not satisfy supported docker range but is newer than this will be marked as untested.' 'engine-supported-range': 'Semver range for supported Docker engine versions. Versions which do not satisfy this range will be marked unsupported in the UI.' 'ingress-ip-domain': 'Wildcard DNS domain to use for automatically generated Ingress hostnames. .. will be added to the domain.' 'server-url': 'Default {appName} install url. Must be HTTPS. All nodes in your cluster must be able to reach this.' 'system-default-registry': 'Private registry to be used for all system Docker images.' 'ui-index': 'HTML index location for the Cluster Manager UI.' 'ui-dashboard-index': 'HTML index location for the {appName} UI.' 'ui-offline-preferred': 'Controls whether UI assets are served locally by the server container or from the remote URL defined in the ui-index and ui-dashboard-index settings. The `Dynamic` option will use local assets in production builds of {appName}.' 'ui-pl': 'Private-Label company name.' 'ui-issues': "Use a url address to send new 'File an Issue' reports instead of sending users to the GitHub issues page." 'telemetry-opt': 'Telemetry reporting opt-in.' 'auth-user-info-max-age-seconds': 'The maximum age of a users auth tokens before an auth provider group membership sync will be performed.' 'auth-user-info-resync-cron': 'Default cron schedule for resyncing auth provider group memberships.' 'cluster-template-enforcement': 'Non-admins will be restricted to launching clusters via preapproved RKE Templates only.' 'auth-user-session-ttl-minutes': 'Custom TTL (in minutes) on a user auth session.' 'auth-token-max-ttl-minutes': 'Custom max TTL (in minutes) on an auth token.' 'kubeconfig-generate-token': 'Automatically generate kubeconfig tokens for users.' 'kubeconfig-token-ttl-minutes': 'Custom max TTL (in minutes) on a kubeconfig token.' 'rke-metadata-config': 'Configure RKE metadata refresh parameters.' 'ui-banners': 'Classification banner is used to display a custom fixed banner in the header, footer, or both.' 'ui-default-landing': 'The default page users land on after login.' 'brand': Folder name for an alternative theme defined in '/assets/brand' editHelp: 'ui-banners': This setting takes a JSON object containing 3 root parameters; banner, showHeader, showFooter. banner is an object containing; textColor, background, and text, where textColor and background are any valid CSS color value. enum: 'ui-default-landing': ember: Cluster Manager vue: Cluster Explorer 'telemetry-opt': prompt: Prompt in: Opt-in to Telemetry out: Opt-out of Telemetry 'ui-offline-preferred': dynamic: Dynamic true: Local false: Remote featureFlags: label: Feature Flags warning: |- Feature flags allow {vendor} to gate certain features behind flags. Features that are off by default should be considered experimental functionality. Some features require a restart of the {vendor} server to change. This will result in a short outage of the API and UI, but not affect running clusters or workloads. restartRequired: "Note: Updating this feature flag requires a restart" restart: title: Waiting for Restart wait: This may take a few moments branding: label: Branding directoryName: Brand Asset Directory Name logos: label: Logo tip: 'Upload a logo to replace the Rancher logo in the top-level navigation header. Image height should be 21 pixels with a max width of 200 pixels. Max file size is 20KB' lightPreview: Light Theme Preview darkPreview: Dark Theme Preview uploadLight: Upload Light Logo uploadDark: Upload Dark Logo useCustom: Use a Custom Logo options: default: Default Rancher Theme suse: SUSE Theme custom: Define a Custom Theme uiPL: label: Private Label Company Name uiIssues: label: Issue Reporting URL uiBanner: label: Fixed Banners text: Text textColor: Text Color background: Background Color showHeader: Show Banner in Header showFooter: Show Banner in Footer color: label: Primary Color tip: You can override the primary color used throughout the UI with a custom color of your choice. useCustom: Use a Custom Color resourceQuota: configMaps: Config Maps limitsCpu: CPU Limit limitsMemory: Memory Limit persistentVolumeClaims: Persistent Volume Claims pods: Pods replicationControllers: Replication Controllers requestsCpu: CPU Reservation requestsMemory: Memory Reservation requestsStorage: Storage Reservation secrets: Secrets services: Services servicesLoadBalancers: Services Load Balancers servicesNodePorts: Service Node Ports projectLimit: label: Project Limit cpuPlaceholder: e.g. 2000 memoryPlaceholder: e.g. 2048 storagePlaceholder: e.g. 50 unitlessPlaceholder: e.g. 50 namespaceDefaultLimit: label: Namespace Default Limit cpuPlaceholder: e.g. 500 memoryPlaceholder: e.g. 1024 storagePlaceholder: e.g. 10 unitlessPlaceholder: e.g. 10 resourceType: label: Resource Type snapshots: title: Snapshots action: create: Create Snapshot card: created: "Created on ''{date}'' at ''{time}''" action: restore: Restore remove: Delete create: title: 'Create a new Snapshot' name: label: Snapshot Name description: label: Description actions: submit: Create back: Cancel info: 'Rancher Desktop will be temporarily unavailable while creating a new Snapshot' empty: icon: icon-search heading: No snapshots found body: Click on Create Snapshot to get started dialog: restore: header: Restore snapshot? info: 'Restoring this snapshot will replace your current installation, including preferences. If the Preferences window is open, it will be automatically closed and any unsaved changes will be discarded.' actions: ok: 'Restore' cancel: 'Cancel' error: header: 'Error restoring snapshot' description: "Failed to restore snapshot ''{snapshot}''.'

    'Rancher Desktop has performed a Factory Reset and will now exit. Please try to address the issue that led to this error before restarting Rancher Desktop and attempting to restore the snapshot again." buttonText: 'Quit Rancher Desktop' delete: header: Permanently delete snapshot? info: 'text' actions: ok: 'Delete' cancel: 'Cancel' restoring: header: 'Restoring {snapshot}' message: "Please wait while snapshot ''{snapshot}'' is being restored. This may take some time depending on your machine's resources.

    Rancher Desktop will be temporarily unavailable during this operation. Once completed, the VM will restart with the {snapshot} snapshot active." actions: cancel: 'Cancel' creating: header: 'Creating {snapshot}' message: "Please wait while snapshot ''{snapshot}'' is being created.'
    'This may take some time depending on your machine's resources.'
    'Rancher Desktop will be temporarily unavailable during this operation.'

    'Once the snapshot is complete, the VM will restart." actions: cancel: 'Cancel' buttons: error: Close generic: header: Snapshot operation in progress message: "Please wait while the snapshot operation is active.
    This may take some time depending on your machine's resources.
    Rancher Desktop will be temporarily unavailable during this operation.

    Once the snapshot process is complete, the VM will restart." showLogs: Show logs info: when: " at {time}" restore: success: "Restored ''{snapshot}''" cancel: 'Cancelled restoration of {snapshot}' create: success: "Created ''{snapshot}''" cancel: 'Cancelled creation of {snapshot}' delete: success: "Deleted ''{snapshot}''" error: 'Delete error: {error}' lock: info: "A snapshot lock file has been detected for an unusually long time. If you believe this is an error, you can try to remove the lock file by running rdctl snapshot unlock." ############################## ### Troubleshooting Page ############################## troubleshooting: title: Troubleshooting description: Use these tools to help identify and resolve issues. kubernetes: title: Kubernetes resetKubernetes: title: Reset Kubernetes description: Resetting Kubernetes will delete all workloads and configuration. buttonText: Reset Kubernetes messageBox: title: Rancher Desktop - Reset Kubernetes message: Reset Kubernetes? checkboxLabel: Delete container images ok: Reset cancel: Cancel resetContainer: title: 'Reset Kubernetes & Container Images' description: All images will be lost and Kubernetes will be reset. buttonText: Reset Container Images general: title: General logs: title: Logs description: Show Rancher Desktop logs buttonText: Show Logs factoryReset: title: Factory Reset description: Factory Reset will remove all Rancher Desktop Configurations. buttonText: Factory Reset messageBox: title: Rancher Desktop - Factory Reset message: Perform a factory reset? detail:

    Doing a factory reset will remove your cluster and all Rancher Desktop settings, and shut down Rancher Desktop. If you intend to continue using Rancher Desktop, you will need to manually start it and go through the initial set up again.

    Are you sure you want to reset everything?

    checkboxLabel: Keep cached Kubernetes images ok: Factory Reset cancel: Cancel needHelp: 'Still having problems? Start a discussion in the #rancher-desktop channel on the Rancher Users Slack or Report an Issue.' ############################## ### Diagnostics Page ############################## diagnostics: results: muted: icon: icon-search heading: No results found body: Try showing muted diagnostics. success: icon: icon-checkmark heading: No problems detected body: Rancher Desktop appears to be functioning correctly. ############################## ### Extensions Page ############################## extensions: icon: icon-extension installed: list: upgrade: Upgrade uninstall: Remove emptyState: icon: icon-extension heading: No extensions installed body: It looks like you don't have any extensions installed yet. Browse the extensions catalog to get started. button: text: Browse Extensions view: emptyState: heading: Extension removed body: '{extensionId} was removed. Please visit the extensions catalog to reinstall the extension or browse for other extensions to enhance your Rancher Desktop experience.' ############################## ### Preferences Page ############################## preferences: actions: banner: reset: Kubernetes will reset after applying changes. restart: Kubernetes will restart after applying changes. error: "There's a problem with the preferences selection." locked: tooltip: Locked due to organization's policy incompatibleTypeWarningPre: 'The option requires' incompatibleTypeWarningPostSelected: 'to be selected.' incompatibleTypeWarningPostDisabled: 'to be unselected.' incompatiblePrefWarningOr: 'or' ############################## ### Integrations Page ############################## integrations: windows: title: WSL Integrations description: "Expose Rancher Desktop's Kubernetes configuration and Docker socket to Windows Subsystem for Linux (WSL) distros" ############################## ### Support Page ############################## support: community: title: SUSE Rancher provides world-class support linksTitle: Community Support learnMore: Find out more about SUSE Rancher Support pricing: Contact us for pricing subscription: haveSupport: Already have support? addSubscription: Add a Subscription ID removeSubscription: Remove your Subscription ID addTitle: Add your SUSE Subscription ID addLabel: "Please enter a valid Subscription ID:" removeTitle: Remove your ID? removeBody: "Note: This will not affect your subscription." suse: title: "Great News - You're covered" editBrand: Customize UI Theme access: title: Get Support text: Login to SUSE Customer Center to access support for your subscription action: SUSE Customer Center promos: one: title: 24x7 Support text: We provide tightly defined SLAs, and offer round the clock support options. two: title: Issue Resolution text: Run SUSE Rancher products with confidence, knowing that the developers who built them are available to quickly resolve issues. three: title: Troubleshooting text: We focus on uncovering the root cause of any issue, whether it is related to Rancher Labs products, Kubernetes, Docker or your underlying infrastructure. four: title: Innovate with Freedom text: Take advantage of our certified compatibility with a wide range of Kubernetes providers, operating systems, and open source software. embedding: retry: Retry unavailable: Cluster Manager UI is not available v1ClusterTools: monitoring: label: Monitoring (Legacy) description: 'Legacy V1 monitoring. V1 Monitoring is deprecated since Rancher 2.5.0. Learn more about migrating to V2 Monitoring.' logging: label: Logging (Legacy) description: 'Legacy V1 logging. V1 Logging is deprecated since Rancher 2.5.0. Learn more about migrating to V2 Logging.' legacy: alerts: Alerts apps: Apps catalogs: Catalogs globalDnsEntries: Global DNS Entries globalDnsProviders: Global DNS Providers logging: Logging notifiers: Notifiers monitoring: Monitoring psps: Pod Security Policies project: label: Project select: "Use the Project/Namespace filter at the top of the page to select a Project in order to see legacy Project features." harvester: tableHeaders: actions: Actions ================================================ FILE: pkg/rancher-desktop/assets/translations/zh-hans.yaml ================================================ ############################## # Special stuff ############################## generic: add: 添加 back: 返回 cancel: 取消 close: 关闭 comingSoon: 即将推出 copy: 复制 create: 创建 created: 创建时间 customize: 定制 default: 默认 disabled: 禁用 done: 完成 enabled: 启用 ignored: 忽略 invalidCron: 无效的 cron 调度 labelsAndAnnotations: 标签和注释 loading: 正在加载中... members: 成员 #na: n/a name: 名称 never: 从不 none: 无 #number: '{prefix}{value, number}{suffix}' overview: 概述 readFromFile: 从文件读取 register: 注册 remove: 移除 resource: |- {count, plural, one {资源} other {资源} } resourceCount: |- {count, plural, one {1 resource} other {# resources} } save: 保存 type: 类型 unknown: 未知 key: 键 value: 值 yes: 是 no: 否 units: time: 5s: 5秒 10s: 10秒 30s: 30秒 1m: 1分钟 5m: 5分钟 15m: 15分钟 30m: 30分钟 1h: 1小时 2h: 2小时 6h: 6小时 1d: 1小时 7d: 7天 30d: 30天 #locale: #en-us: English #zh-hans: 简体中文 #none: (None) nav: title: 仪表盘 #backToRancher: Cluster Manager clusterTools: 集群工具 shell: 命令行 import: 导入 YAML 文件 home: 返回首页 support: 帮助 group: cluster: 集群 inUse: 更多资源 rbac: RBAC serviceDiscovery: 服务发现 starred: 已收藏 storage: 存储 workload: 工作负载 monitoring: 监控 ns: all: 全部命名空间 clusterLevel: 集群资源 #namespace: "{name}" namespaced: 命名空间资源 orphan: 不在项目中 project: "项目名称: {name}" system: 系统命名空间 user: 用户命名空间 apps: 应用商店 categories: explore: 浏览集群 multiCluster: 全局应用 configuration: 配置 search: placeholder: 输入关键词,搜索集群 noResults: 没有与关键词匹配的集群 resourceSearch: label: 资源搜索 placeholder: 输入关键词,搜索资源 product: clusterGroup: 集群应用 globalGroup: 全局应用 apps: 应用市场 auth: 用户及认证方式 backup: 备份 cis: CIS 基线测试 ecm: 集群管理员 explorer: 集群浏览器 fleet: Fleet #longhorn: Longhorn manager: 管理集群 #gatekeeper: OPA Gatekeeper #istio: Istio logging: 日志 #rio: Rio settings: 全局设置 monitoring: 监控 suffix: #percent: "%" #cpus: CPUs #ib: iB revisions: |- {count, plural, =1 { 版本 } other { 版本 } } seconds: |- {count, plural, =1 { 秒 } other { 秒 } } sec: Sec times: |- {count, plural, =1 { 次 } other { 次 } } ############################## # Components & Pages ############################## accountAndKeys: title: 账户和API密钥 account: title: 账户 change: 修改密码 apiKeys: title: API密钥 notAllowed: 对不起,您没有权限编辑API密钥 add: description: label: 描述 placeholder: 可选择输入一个描述,以帮助您识别该API密钥。 label: 创建API密钥 expiry: label: 自动过期 options: never: 从不过期 day: 一天后过期 month: 一个月后过期 year: 一年后过期 custom: 自定义过期之间 maximum: "{value} - 最大有效期" customExpiry: options: minute: 分钟 hour: 小时 day: 日 month: 月 year: 年 scope: 适用范围 noScope: 没有适用范围 info: #accessKey: Access Key #secretKey: Secret Key #bearerToken: Bearer Token saveWarning: 请在云端或本地妥善保存以上的信息! 如果丢失这些信息,你需要创建一个新的API密钥。 keyCreated: 已创建一个新的API密钥。 bearerTokenTip: "Access Key 和 Secret Key 可以作为 HTTP Basic auth 的用户名和密码发送,以授权请求。您也可以将它们组合起来作为一个Bearer token使用。" ttlLimitedWarning: 由于系统配置的原因,该API密钥的到期时间缩短了。 authConfig: accessMode: label: '配置够登录和使用{vendor}的人员名单' required: '只有授权用户和用户组能够访问。' restricted: '允许集群和项目的成员,以及授权用户和用户组访问' unrestricted: '允许所有用户访问' allowedPrincipalIds: title: 授权用户和用户组 associatedWarning: '注意:您认证为的{provider} 用户将作为您当前登录的 {vendor} 用户的替代登录方式({username})。' github: clientId: label: 账户名 clientSecret: label: 密码 form: app: label: 应用名称 value: '输入一个应用名称,例如:我的{vendor}' calllback: label: 授权回调URL description: label: 应用描述 value: '选填项,可留空' homepage: label: 主页URL地址 instruction: '请在表格中输入以下值:' prefix: |-
  • 点击这里,在新窗口中进入GitHub应用设置。
  • 点击 "OAuth App "标签。
  • 点击 "新建OAuth应用 "按钮。
  • suffix: |-
  • 点击 "注册应用"
  • 复制并粘贴新创建的OAuth应用程序的客户ID和客户秘密到下面的字段中。
  • host: label: GitHub企业版 placeholder: 例如:github.mycompany.example target: label: 您想使用哪种GitHub呢? private: GitHub企业版的私人安装 public: 公开的GitHub.com table: #server: Server #clientId: Client ID googleoauth: adminEmail: 电子邮件地址 domain: 域名 oauthCredentials: label: OAuth 认证信息 tip: 包含OAuth Credentials的JSON文件可以在Google API开发者控制台中找到。 serviceAccountCredentials: label: Service Account 认证信息 tip: 包含Service Account 认证信息的JSON文件可以在Google API开发者控制台中找到。 steps: 1: title: '第一步:对于标准的Google,点击这里在新窗口中进入应用程序设置。' body: 1: 登录到您的账户,然后导航到 "APIs & Services",然后选择 "OAuth consent screen"。 2: 'Authorized domains:' 3: 'Application homepage link: ' 4: '在Google APIs的作用域下,启用 "email"、"profile "和 "openid"。' 5: '单击保存,保存以上修改' topPrivateDomain: '顶级域' 2: title: '第二步:导航到 "Credentials"标签,创建你的OAuth客户端ID。' body: 1: '选择 "Create Credentials"下拉菜单,选择 "OAuth clientID",然后选择 "Web application"。' 2: 'Authorized JavaScript origins:' 3: 'Authorized redirect URIs:' 4: '点击 "Create",然后点击 "Download JSON"按钮。' 5: '在OAuth凭证框中上传下载的JSON文件。' 3: title: '第三步:创建服务账户凭证' introduction: '按照这里指南。' body: 1: 创建一个 service account。 2: 为这个service account生成密钥。 3: 在你的google域名中添加service account作为OAuth客户端。 ldap: freeipa: 配置 FreeIPA server activedirectory: 配置 Active Directory 账户 openldap: 配置 OpenLDAP server defaultLoginDomain: label: 默认登录页面的域名 placeholder: 例如:mycompany hint: 如果用户在没有指定域的情况下登录,将使用该域。 cert: 证书 disabledStatusBitmask: 禁用状态 比特掩码 groupDNAttribute: 用户组域名属性 groupMemberMappingAttribute: 用户组成员映射属性 groupMemberUserAttribute: 用户组成员属性 groupSearchBase: label: 用户组内搜索 #placeholder: 'ou=groups,dc=mycompany,dc=com' hostname: 主机名/IP loginAttribute: 登录属性 nameAttribute: 名称属性 nestedGroupMembership: label: 属于多个用户组的用户 options: direct: 搜索只属于单个用户组的用户 nested: 搜索只属于单个用户组的用户和属于多个用户组的用户 objectClass: 对象类 password: 密码 port: 端口 customizeSchema: 自定义模式 users: 用户 groups: 组 searchAttribute: 搜索属性 searchFilter: 搜索过滤条件 serverConnectionTimeout: 服务器连接超时 serviceAccountDN: 服务账户的独特名称 serviceAccountPassword: Service Account 密码 serviceAccountInfo: Rancher需要一个对所有能够登录的域都有只读访问权的服务账户,这样我们就可以在用户使用API密钥进行请求时,确定用户是什么组的成员。 starttls: label: Start TLS tip: 通过在连接过程中使用 TLS 封装来升级非加密连接。不能与TLS结合使用。 tls: TLS userEnabledAttribute: 用户启用属性 userMemberAttribute: 用户组员属性 userSearchBase: label: 搜索用户 placeholder: '例如:ou=users,dc=mycompany,dc=com' username: 用户名 usernameAttribute: 用户名属性 table: server: Server clientId: Client ID saml: entityID: Entity ID字段 UID: UID字段 adfs: 配置AD FS 账户 api: Rancher API Host cert: label: 证书 placeholder: 粘贴证书,以-----BEGIN CERTIFICATE----- 开始。 displayName: 显示名称字段 groups: 用户组字段 key: label: 私钥 placeholder: 粘贴私钥,一般以-----RSA PRIVATE KEY----- 开始。 keycloak: 配置Keycloak账户 metadata: label: Metadata XML placeholder: 粘贴IDP Metadata XML okta: 配置Okta账户 ping: 配置Ping账户 shibboleth: 配置shibboleth账户 showLdap: 配置OpenLDAP服务器 userName: 用户名字段 azuread: tenantId: 租户ID applicationId: 应用ID endpoint: 端点 #graphEndpoint: Graph Endpoint #tokenEndpoint: Token Endpoint #authEndpoint: Auth Endpoint stateBanner: disabled: '已禁用{provider} 。' enabled: '已启用{provider} 。' testAndEnable: 测试和启用认证 authGroups: actions: refresh: 刷新用户组成员名单 assignRoles: 为当前用户组成员分配全局角色 assignEdit: assignTitle: 为当前用户组分配全局角色 assignTo: title: |- {count, plural, =1 { 分配集群到… } other { 分配 {count} 集群到… } } labelsTitle: |- {count, plural, =1 { 分配集群到… } other { 分配 {count}集群到… } } workspace: 工作空间 asyncButton: apply: action: '应用' success: '已应用' waiting: '正在应用…' continue: action: '继续' success: '已保存' waiting: '正在保存…' copy: action: 单击复制 success: 已复制 create: action: '创建' success: '已创建' waiting: '正在创建…' default: action: 正在执行 error: 错误 success: 成功 waiting: 等待中 delete: action: '删除' success: '已删除' waiting: '正在删除…' disable: action: '禁用' success: '已禁用' waiting: '正在禁用…' activate: action: 激活 waiting: 正在激活… success: 已激活 deactivate: action: 停用 waiting: 正在停用… success: 已停用 done: action: '完成' waiting: '正在保存…' success: '已保存' download: action: '下载' waiting: '正在下载…' success: '下载完成' edit: action: 保存 success: 已保存 waiting: 正在保存… enable: action: '启用' waiting: '正在启用…' success: '已启用' finish: action: '完成' waiting: '正在处理中…' success: '已完成' import: action: 导入 success: 已导入 waiting: 正在导入… install: action: '安装' waiting: '开始安装…' success: '安装完成' refresh: action: '' actionIcon: '刷新' waiting: '' waitingIcon: '刷新中' success: '' successIcon: '成功' error: '' errorIcon: '出错啦' remove: action: 移除 success: 已移除 waiting: 正在移除… upgrade: action: 升级 success: 完成升级 waiting: 已开始升级… backupRestoreOperator: backupFilename: 备份文件名称 deleteTimeout: label: 删除超时 tip: 在删除定标器强制删除之前,等待资源删除成功的秒数。 deployment: rancherNamespace: Rancher 资源集命名空间 size: 大小 storage: label: 默认存储位置 options: defaultStorageClass: '使用({name})作为默认存储类' none: 不使用默认存储类 pickPV: 使用已有的持久卷 pickSC: 使用已有的存储类 s3: 使用Amazon S3对象存储服务 persistentVolume: label: 持久存储卷 storageClass: label: 存储类 tip: '配置一个默认保存所有备份的存储位置。您可以选择对每个备份进行覆盖,但仅限于使用与 S3 兼容的对象存储。' warning: '此 {type} 没有将其回收策略设置为 "保留"。 如果卷被更改或未绑定,您的备份可能会丢失。' encryption: '这个{type}没有将其回收策略设置为 "保留"。 如果该卷被改变或变得不受约束,你的备份可能会丢失。' encryptionConfigName: backuptip: 'cattle-resource-system命名空间中具有encryption-provider-config.yaml密钥的任何秘密。
    此文件的内容是从此备份中执行还原所必需的,Rancher Backup 不会存储这些内容。' label: 加密配置密钥 options: none: 存储未加密的备份内容。 secret: '使用 加密配置秘密(推荐)对备份进行加密。' restoretip: '如果备份是在启用加密的情况下进行的,则应在还原过程中使用包含相同加密提供者配置的秘密。' warning: '该文件的内容是从该备份中执行还原所必需的,Rancher 备份不会存储。' lastBackup: 上一次备份 nextBackup: 下一次备份 noResourceSet: 您必须在此命名空间中定义一个资源集来创建备份CR。 prune: label: 修剪 tip: 删除备份中不存在的 Rancher 管理的资源。(推荐使用) resourceSetName: 资源集 restoreFrom: existing: 使用已有的备份配置恢复 default: 使用默认的存储目标恢复 s3: 使用一个 S3 兼容的对象存储恢复 retentionCount: label: 备份保留数量 units: |- {count, plural, =1 { 文件 } other { 文件 } } s3: bucketName: 桶名称 credentialSecretName: 密钥凭证 endpoint: 端点 endpointCA: 端点 CA folder: 文件夹 insecureTLSSkipVerify: 跳过TLS认证 region: 区域 storageLocation: 存储位置 titles: backupLocation: 备份来源 location: 存储位置 s3: S3 schedule: label: 定时备份策略 options: disabled: 单次备份 enabled: 重复备份 placeholder: 例如:@midnight or 0 0 * * * storageSource: configureS3: 使用兼容 S3 的对象存储作为存储位置 useBackup: 使用备份 CR 上指定的 S3 位置 useDefault: 使用安装时配置的默认存储位置 targetBackup: 目标备份 catalog: app: managed: 管理 section: notes: 发布说明 readme: Chart 自述 resources: 资源 values: YAML charts: all: 所有 categories: all: 所有类别 #certified: #other: Other #partner: Partner #rancher: Rancher header: Chart Apps noCharts: '没有可用的 chart,你有添加 chart 仓库吗?' noWindows: 您的应用商店没有包含能部署在 Windows 集群上的 chart。 search: 过滤 install: action: goToUpgrade: 编辑/升级 ignoreWarning: 忽略警告,继续升级 appReadmeGeneric: 此 chart 没有针对 rancher 的自述文件。查看 Helm 自述文件,了解更多可用配置选项及其用法。 chart: Chart error: requiresFound: '必须先安装${name},才能安装这个Chart。' requiresMissing: '这个Chart需要另一个提供{name}的Chart,但没有找到。' insufficientCpu: 'T这个Chart需要{need, number}个CPU核,但集群只有{have, number}个可用。' insufficientMemory: '这个Chart需要{need}的内存,但集群只有{have}可用。' header: install: '安装 {name}' installGeneric: 安装 Chart upgrade: '升级 {name}' helm: #atomic: Atomic cleanupOnFail: 失败时的清理 crds: 应用自定义资源定义 dryRun: 空运行 force: 强制 historyMax: label: 保留最后一个 unit: |- {value, plural, =1 { 版本 } other { 版本 } } hooks: 执行 chart 钩子 openapi: 验证 OpenAPI 模式 resetValues: 重置值 timeout: label: 超时 unit: |- {value, plural, =1 { 秒 } other { 秒 } } wait: 等待 namespaceIsInProject: "这个Chart的目标命名空间{namespace},已经存在,不能添加到不同的项目中。" project: 安装到项目 section: appReadme: 自述 chartOptions: Chart 配置选项 helm: Helm 部署选项 readme: Helm 自述 #valuesYaml: Values YAML version: 版本 versions: current: '{ver} (current)' linux: '{ver} (Linux-only)' windows: '{ver} (Windows-only)' operation: tableHeaders: #action: Action releaseName: 版本名称 releaseNamespace: 版本命名空间 repo: action: refresh: 刷新 #all: All gitBranch: label: Git 分支 placeholder: 例如:master gitRepo: label: Git Repo URL placeholder: '例如:https://github.com/your-company/charts.git' name: rancher-charts: Rancher rancher-partner-charts: Partners target: git: 包含定义了 Helm chart 的 Git 仓库。 http: 指向 Helm 生成的索引 http(s) URL label: 目标类型 url: label: Index URL placeholder: '例如:https://charts.rancher.io' tools: header: 集群工具 action: install: 安装 upgrade: 升级 edit: 编辑 remove: 移除 changePassword: title: 修改密码 cancel: 取消 deleteKeys: label: 删除所有的API密钥 changeOnLogin: label: 首次登陆账户时,要求用户立即修改密码。 generatePassword: label: 为用户生成随机密码 currentPassword: label: 当前在使用的密码 userGen: newPassword: label: 新密码 confirmPassword: label: 确认新密码 randomGen: generated: label: 为用户生成随机密码 newGeneratedPassword: 推荐密码 errors: missmatchedPassword: 前后两次输入的密码不匹配 failedToChange: 无法修改密码 failedDeleteKey: 无法删除单个API密钥 failedDeleteKeys: 无法删除多个API密钥 chartHeading: overview: 概述 #poweredBy: "Powered by:" cis: addTest: 添加测试 ID alertNeeded: Alerting must be enabled within the CIS chart questions.yaml. This requires that Rancher's Monitoring and Alerting app is installed and the Receivers and Routes are configured to send out alerts. alertOnComplete: 扫描完成告警 alertOnFailure: 扫描失败告警 benchmarkVersion: Benchmark 版本 clusterProvider: 提供集群的厂商 cronSchedule: label: 定时调度 placeholder: "例如:0 * * * *" customConfigMap: 自定义 Benchmark 配置映射 deleteBenchmarkWarning: |- {count, plural, =1 { 任何使用该基准版本的配置文件将不再工作。 } other { 任何使用这些基准版本的配置文件将不再工作。 } } deleteProfileWarning: |- {count, plural, =1 { 任何使用此配置文件的定时扫描将会失效。 } other { 任何使用这些配置文件的定时扫描将会失效。 } } downloadAllReports: 下载所有保存的报告 downloadLatestReport: 下载最新报告 downloadReport: 下载报告 maxKubernetesVersion: 允许的最大 Kubernetes 版本 minKubernetesVersion: 允许的最小 Kubernetes 版本 noProfiles: 此集群类型没有有效的 ClusterScanProfiles 可供选择。 noReportFound: 未找到扫描报告 profile: 配置文件 retention: 保留数 reports: 报告 scan: description: 描述 fail: 失败 lastScanTime: 最后扫描时间 notApplicable: 'N/A' number: 序号 pass: 通过 remediation: 补救 scanDate: 扫描日期 scanReport: 扫描报告 skip: 跳过 total: 总共 warn: 警告 scheduling: enable: 定时运行扫描 disable: 运行单次扫描 scoreWarning: label: 扫描结果为 "warn" 状态 protip: 没有失败的扫描将被默认标记为 “通过”,即使一些测试生成 “warn” 输出。此行为可以通过从本节中选择 “fail” 选项来更改。 testID: Test ID testsToSkip: 跳过测试 testsSkipped: 已跳过的测试 cluster: provider: #aliyun: Alibaba ACK #aliyunecs: Aliyun ECS aws: Amazon AWS #amazonec2: Amazon EC2 #amazoneks: Amazon EKS #azure: Azure #azureaks: Azure AKS #baidu: Baidu CCE #cloudca: Cloud.ca custom: 自定义 #digitalocean: DigitalOcean #docker: Docker #exoscale: Exoscale #googlegke: Google GKE #huaweicce: Huawei CCE import: 导入已有集群 #k3s: K3s #kubeAdmin: KubeADM #linode: Linode local: Local #minikube: Minikube #oci: Oracle Cloud Infrastructure #openstack: OpenStack #oracleoke: Oracle OKE #otc: Open Telekom Cloud other: 其他 #packet: Packet pinganyunecs: 平安云 ECS #rackspace: RackSpace #rancherkubernetesengine: RKE #rke2: RKE Government #rke: RKE #rkeWindows: Windows #softlayer: SoftLayer #tencenttke: Tencent TKE #upcloud: UpCloud #vmwarevsphere: vSphere #zstack: ZStack providerGroup: create-template: 使用模板创建集群 create-kontainer: 在托管的 Kubernetes 提供商中创建集群 create-machine: 在新建的节点上使用 RKE 创建集群 create-custom: 在现有的节点上使用 RKE 创建集群 register-kontainer: 在托管的 Kubernetes 提供商中注册一个现有的集群 register-custom: 导入 Kubernetes 集群 credential: label: 云凭证 name: label: 凭证名称 placeholder: 请为这个凭证输入一个名称 aws: accessKey: label: Access Key placeholder: 请输入您的 AWS Access Key secretKey: label: SecretKey placeholder: 请输入您的 AWS Secret Key defaultRegion: label: 默认区域 help: 创建群组时默认使用的区域。 也用于验证此凭证是否有效。 digitalocean: accessToken: #label: Access Token placeholder: 请输入您的 DigitalOcean API Access Token help: 从 DigitalOcean Applications & API中复制和粘贴个人访问令牌。 name: label: 集群名称 placeholder: 请输入集群名称,该名称不能与其他集群名称相同 description: label: 集群描述 placeholder: (选填项)请输入关于该集群的描述 import: 导入已有集群 kubernetesVersion: label: Kubernetes 版本 machinePool: nodeTotals: label: etcd: "{count} 个etcd节点" controlPlane: "{count} 个Control Plane节点" worker: "{count} 个Worker节点" tooltip: etcd: |- {count, plural, =0 { 一个集群至少需要一个etcd节点才能使用,请重新选择节点数量。 } =1 { 只有1个etcd节点的集群是不具备容错能力的。 } =2 { 集群的节点数应该是奇数。 具有2个etcd节点的集群是不具备容错能力的。 } =3 {} =4 { 集群内的节点数量应该为任意大于1的奇数,请重新选择节点数量。 } =5 {} =6 { 集群内的节点数量应该为任意大于1的奇数,请重新选择节点数量。 } =7 {} other { 我们不建议您在集群内创建多于7个节点。 } } controlPlane: |- {count, plural, =0 { 每个集群至少需要一个control plane节点才可以使用。 } =1 { 只有一个control plane节点的集群是不具备容错能力的。 } other {} } worker: |- {count, plural, =0 { 每个集群至少需要一个worker节点才可以使用 } =1 { 只有一个worker节点的集群是不具备容错能力的。 } other {} } name: label: 节点池名称 placeholder: 默认情况下会随机生成一个节点池名称 #machineConfig: #aws: #sizeLabel: |- #{apiName}: {cpu}, {memory, number} GiB Memory, {storageSize, plural, #=0 {EBS-Only} #other {{storageSize, number} GiB {storageType}} #} #digitalocean: #sizeLabel: |- #{plan, select, #s {Basic: } #g {General: } #gd {General: } #c {CPU: } #m {Memory: } #so {Storage: } #standard {Standard: } #other {} #}{memoryGb} GB, {vcpus, plural, #=1 {#vCPU} #other {#vCPUs} #}, {disk} GB Disk ({value}) clusterIndexPage: hardwareResourceGauge: consumption: "{suffix} {total} {units} 中的 {useful}" coresReserved: CPU 预留 coresUsed: CPU 使用 podsUsed: Pods 预留 ramReserved: Memory 预留 ramUsed: Memory 使用 header: 集群仪表盘 resourceGauge: totalResources: 资源总额 sections: events: label: 事件 resource: label: 事件详情 date: label: 更新时间 clusterMetrics: label: 集群指标 etcdMetrics: label: Etcd 指标 k8sMetrics: label: Kubernetes 组件指标 gatekeeper: buttonText: 配置 OPA Gatekeeper disabled: 未配置 OPA Gatekeeper label: 违反 OPA Gatekeeper 的限制规定 noRows: 所有的 OPA Gatekeeper 限制都符合规定 nodes: label: 节点不健康 noRows: 所有节点都处于健康状态 configmap: tabs: data: label: 数据 protip: 请在此处输入 UTF-8 文本数据 binaryData: label: 二进制数据 containers: sortableTables: noRows: 没有要显示的容器 images: title: Images sortableTables: noRows: 没有要显示的镜像 portForwarding: sortableTables: noRows: 没有可显示的端口转发规则 containerResourceLimit: cpuPlaceholder: 例如:1000 helpText: 请配置容器可以使用的默认资源配额 helpTextDetail: 容器可以使用的的默认资源配额 label: 容器默认资源限制 limitsCpu: CPU 限制 limitsMemory: 内存限制 memPlaceholder: 例如:128 requestsCpu: CPU 预留 requestsMemory: 内存预留 cruResource: backToForm: 返回表单编辑 backBody: 返回表单编辑不会保留对 YAML 做出的所有更改 cancelBody: 返回表单编辑不会保留对 YAML 做出的所有更改 confirmBack: "确认" confirmCancel: "确认" reviewForm: "继续编辑 YAML" reviewYaml: "继续编辑 YAML" previewYaml: 以 YAML 文件编辑 detailText: collapse: 隐藏 binary: '<二进制数据:{n, number} bytes>' empty: '' plusMore: |- {n, plural, =1 {+ 1 more char} other {+ {n, number} 更多 Chars} } etcdInfoBanner: hasLeader: "Etcd有一个领导者" leaderChanges: "领导者变化的次数" failedProposals: "失败的proposal数量" fleet: cluster: summary: 资源概要 nonReady: 非就绪包 fleetSummary: state: success: '就绪' #info: 'Transitioning' warning: '警告' error: '错误' unknown: '未知' gitRepo: tabs: resources: 资源 unready: 未就绪 auth: label: 认证 caBundle: label: 证书 placeholder: "粘贴一个或多个证书,以“-----BEGIN CERTIFICATE----” 作为开头。" paths: label: 路径 placeholder: 例如:/directory/in/your/repo addLabel: 添加路径 empty: 默认使用的是 repo 的根目录。 要使用一个或多个不同的目录,请在这里添加。 repo: label: 代码库 URL 地址 placeholder: '例如:https://github.com/rancher/fleet-examples.git' ref: label: Watch branch: 分支 revision: 修改 branchLabel: 分支名称 branchPlaceholder: 例如:master revisionLabel: 标签或 Commit Hash revisionPlaceholder: 例如:v1.0.0 serviceAccount: label: Service Account 名称 placeholder: "(选填项)在目标集群中使用Service Account" targetNamespace: label: 目标命名空间 placeholder: "(选填项)要求所有资源都在此命名空间内" target: selectLabel: 目标类型 advanced: 高级选项 cluster: 集群 clusterGroup: 集群组 label: 部署到 labelLocal: 部署方式 targetDisplay: advanced: 高级选项 cluster: "集群" clusterGroup: "组" all: 全部 none: None local: 本地 tls: label: TLS证书校验 verify: 需要提供有效的证书 specify: 指定接受的附加证书 skip: 接受任何证书(不安全) workspace: label: 工作空间 clusterGroup: selector: label: 集群选择器 matchesAll: 匹配到 {total, number} 个集群 matchesNone: 与现有的集群都不匹配 matchesSome: |- {matched, plural, =1 {与现有 {total, number} 个集群中的 1 个集群 "{sample}" 匹配} other {现有 {total, number} 个集群,与其中的 {matched, number} 匹配,包括 "{sample}"} } footer: docs: Rancher 官方文档 download: 下载 CLI forums: 论坛 issue: 提交 GitHub Issue slack: Slack 讨论群 gatekeeperConstraint: match: title: 匹配 tab: enforcementAction: title: 执行动作 rules: title: 规则 sub: labelSelector: addLabel: 添加 title: 标签选择器 namespaces: sub: excludedNamespaces: 排除命名空间 namespaces: 命名空间 namespaceSelector: addNamespace: 添加命名空间 title: 命名空间选择器 scope: title: 范围 title: 命名空间 parameters: addParameter: 添加参数 editAsForm: 作为表格编辑 editAsYaml: 作为 YAML 编辑 title: 参数 template: 模板 violations: title: 违反规定 gatekeeperIndex: #poweredBy: OPA Gatekeeper unavailable: OPA Gatekeeper 不在 system-charts 应用商店中 violations: 违反规定 glance: created: 创建时间 cpu: CPU 使用量 memory: 内存 nodes: total: label: |- {count, plural, =1 { 节点数 } other { 总节点 } } #pods: Pods provider: 提供商 version: Kubernetes 版本 grafanaDashboard: failedToLoad: 加载表格失败 reload: 重新加载 grafana: Grafana graphOptions: detail: 详情 summary: 概述 refresh: 刷新 range: 范围 hpa: detail: currentMetrics: header: 当前指标 noMetrics: 没有当前指标 metricHeader: '{source} 指标' metricIdentifier: name: label: 指标名称 placeholder: 例如:packets-per-second selector: label: 添加 Selector metricTarget: averageVal: label: 平均值 quantity: label: 数量 type: label: 类型 utilization: label: 平均利用率 value: label: 值 metrics: headers: metricName: 名称 objectKind: 对象类型 objectName: 对象名称 quantity: 数量 resource: 资源名称 targetName: 目标名称 value: 值 source: 数据源 objectReferance: api: label: 引用的API版本 placeholder: 例如:apps/v1beta1 kind: label: 引用类型 placeholder: 例如:Deployment name: label: 引用名称 placeholder: 例如:php-apache tabs: labels: 标签 metrics: 指标 target: 目标 workload: 工作负载 types: cpu: CPU memory: 内存 warnings: custom: 为了使用HPA的自定义指标,你需要部署自定义metric server,如prometheus适配器。 external: 为了使用HPA的外部指标,你需要部署外部metric server,如prometheus适配器。 noMetric: 为了使用HPA的资源指标,您需要部署metric server。 resource: 选定的目标参考在规格上没有正确的资源请求。否则,HPA指标将不会有任何影响。 workloadTab: current: 当前的副本 last: 最后一个刻度时间 max: 最大副本数量 min: 最小副本数量 targetReference: 目标参考 import: title: 导入 YAML defaultNamespace: label: 默认命名空间 success: |- Applied {count, plural, =1 {1 Resource} other {#Resources} } ingress: certificates: addCertificate: 添加证书 addHost: 添加主机 certificate: label: 证书 - 密钥名称 doesntExist: 所选证书不存在 defaultCertLabel: 默认 Ingress Controller 证书 headers: certificate: 证书 hosts: 主机 host: label: 主机 placeholder: 例如:example.com label: 证书 removeHost: 移除 defaultBackend: label: 默认后端 noServiceSelected: 没有配置默认后端 port: label: 端口 placeholder: 例如 80 或 http targetService: label: 目标服务 doesntExist: 您选择的服务不存在 warning: "警告:默认后端在整个集群中全局使用" rules: addPath: 添加路径 addRule: 添加规则 headers: pathType: 路径类型 path: 路径 port: 端口 target: 目标服务 certificates: 证书 hostname: 主机名 path: label: 路径 placeholder: 例如:/foo port: label: 端口 placeholder: 例如:80 或 http removePath: 删除路径 requestHost: label: 请求主机 placeholder: 例如:example.com target: label: 目标服务 doesntExist: 您选择的服务不存在 title: 规则 rulesAndCertificates: title: 规则和证书 defaultCertificate: 默认 target: default: 默认 internalExternalIP: none: 无 istio: links: kiali: label: Kiali description: 可视化服务网状结构中的服务以及它们是如何连接的。要想让 Kiali 显示数据,需要安装 Prometheus。如果您需要监控解决方案,请安装 Rancher 的监控。 jaeger: label: Jaeger description: 监控并排除基于微服务的分布式系统的故障。 disabled: '没有安装{app}应用' cni: 启用 CNI customOverlayFile: label: 自定义覆盖文件 tip: '覆盖文件允许在基本的 Rancher Istio 安装之上进行额外的配置。您可以利用IstioOperator API对所有组件进行更改和添加,并通过此覆盖 YAML 文件应用这些更改。' description: 'Rancher Istio Helm Chart 为您安装了一个最小的 Istio 配置,以便您开始与您的应用程序集成。 如果您想获得有关 Istio 的更多信息,请访问 https://istio.io/latest/docs/concepts/what-is-istio/。' egressGateway: 启用 Egress 网关 ingressGateway: 启用 Ingress 网关 istiodRemote: 启用 istiodRemote kiali: 启用 Kiali pilot: 启用 Pilot policy: 启用 Policy poweredBy: 由Istio支持 telemetry: 启用遥测 titles: components: 组件 customAnswers: 自定义回复 advanced: 高级选项 description: 描述 tracing: 启用 Jaeger 跟踪 (limited) v1Warning: 请在安装这个版本之前卸载 istio-system 命名空间中的当前 Istio 版本。 labels: addLabel: 添加 addSetLabel: 添加或配置标签 addAnnotation: 添加 labels: title: 标签 annotations: title: 注释 landing: clusters: 集群 # taken from Ember: https://github.com/rancher/ui/blob/master/app/components/modal-home/template.hbs releaseNotes: '
    • Cluster Explorer: 新的仪表盘提供了对Rancher管理的集群的更深入理解。
      • 管理所有Kubernetes集群资源,包括来自Kubernetes运营商生态系统的定制资源
      • 从我们新的Apps & Marketplace部署和管理Helm Chart。
      • 在一个新的类似IDE的查看器中查看日志并与kubectl shell互动
    • 由Prometheus提供的监控和警报:允许管理定制的Grafana仪表盘,并为AlertManager提供定制。
    • 由Banzai Cloud提供日志: 自定义FluentBit和Fluentd的配置,并将日志运送到远程数据存储。
    • 由kube-bench提供的CIS扫描: 扩展支持为EKS和GKE平台定制的CIS扫描,并对任何Kubernetes发行版进行通用扫描
    • Istio 1.7+: 允许用户部署多个Ingress和Egress网关
    • Rancher 由Fleet提供的持续交付: Fleet是一个Rancher内置的部署工具,用于在多个集群中从Git源码库交付应用程序和配置。
      • 部署由manifests、kustomize或Helm定义的任何Kubernetes资源
      • 使用staged checkout和pull-based的更新模式将部署扩展到任何数量的集群中
      • 将集群组织成组,以便更容易管理
      • 将Git源存储库映射到目标集群组上
    • EKS生命周期管理功能增强
      • 集群创建已得到加强,支持管理节点组、私人访问和控制平面记录
      • 注册现有的EKS集群允许管理升级和配置
    • Rancher Server备份:
      • 在不能访问etcd数据库的情况下备份Rancher服务器
      • 将数据恢复到任何Kubernetes集群中
    ' seeWhatsNew: 了解更多关于该版本的改进和新功能。 whatsNewLink: "2.5的新内容" learnMore: 了解更多 migration: title: 迁移帮助 body: 阅读集群管理器用户的迁移指南--你需要利用扩展的集群资源管理器的一切优势 community: title: 社区支持 docs: Rancher文档 forums: 论坛 commercial: title: 付费支持 body: 如需了解付费支持,请单击这里。 landingPrefs: title: 你想在登录时看到什么? options: thisScreen: 当前页面 lastVisited: 上一次登录时最后访问的页面 custom: 自定义首页 defaultOverview: 默认集群({cluster})的概览页面 appsAndMarketplace: 应用市场页面 fleet: Fleet页面 welcomeToRancher: 欢迎使用Rancher! logging: clusterFlow: noOutputsBanner: 在选定的命名空间中没有集群输出 flow: clusterOutputs: doesntExistTooltip: 该集群输出不存在 label: 集群输出 matches: label: 匹配 addSelect: 添加包含规则 addExclude: 添加排除规则 filters: label: 过滤 outputs: doesntExistTooltip: 该集群输出不存在 label: 输出 install: k3sContainerEngine: K3S 容器引擎 enableAdditionalLoggingSources: 启用增强的云日志收集服务 dockerRootDirectory: Docker根目录 elasticsearch: host: 主机 scheme: 主题 port: 端口 indexName: 索引名称 user: 用户名 password: 密码 caFile: label: CA 证书文件 clientCert: label: 客户端证书 placeholder: 粘贴客户端证书 clientKey: #label: Client Key #placeholder: 粘贴 client key #clientKeyPass: Client Key Pass kafka: #brokers: Brokers defaultTopic: 默认 Topic saslOverSsl: 通过SSL实现SASL scramMechanism: Scram 机制 username: 用户名 password: 密码 sslCaCert: label: SSL CA 证书 placeholder: 请输入 CA 证书 sslClientCert: label: SSL 客户端证书 placeholder: 请把客户端证书粘贴在 CA 证书内 sslClientCertChain: label: SSL 客户端证书链 placeholder: 请输入 SSL 客户端证书链 sslClientCertKey: SSL 客户端证书密钥 loki: #url: URL tenant: 租户 username: 用户名 password: 密码 configureKubernetesLabels: 以类似 Prometheus 的格式配置 Kubernetes 元数据 extractKubernetesLabels: 提取 Kubernetes 标签作为 Loki 标签 dropSingleKey: 如果一条记录只有 1 个键,那么只需将日志行设置为该值并丢弃该键 caCert: CA 证书 cert: 证书 key: 密钥 awsElasticsearch: #url: URL #keyId: Key Id #secretKey: Secret Key #azurestorage: #storageAccount: Storage Account #accessKey: Access Key #container: Container #path: 路径 #storeAs: Store As #cloudwatch: #keyId: Key Id #secretKey: Secret Key #endpoint: Endpoint #region: Region datadog: #apiKey: API Key #useSSL: Use SSL useCompression: 使用压缩 #host: Host file: path: 路径 gcs: project: 项目 credentialsJson: 凭证 bucket: 桶名称 path: 路径 overwriteExistingPath: 覆盖现有的路径 #kinesisStream: #streamName: Stream Name #keyId: Key Id #secretKey: Secret Key #logdna: #apiKey: API Key #hostname: Hostname #app: App #logz: #url: URL #port: Port token: API 令牌 enableCompression: 启用压缩 newrelic: apiKey: API 密钥 #licenseKey: License Key #baseURI: Base URI sumologic: endpoint: 端点 sourceName: 源名称 syslog: host: syslog 主机地址 port: 端口 transport: 传输 insecure: 不安全的 trustedCaPath: 受信 CA 路径 format: title: 格式 type: 类型 addNewLine: 添加新行 #messageKey: Message Key buffer: #title: Buffer tags: 标签 chunkLimitSize: 存储块大小限制 chunkLimitRecords: 块限制 chunkLimitRecords totalLimitSize: 总限制大小 flushInterval: 冲洗时间间隔 #timekey: Timekey #timekeyWait: Timekey Wait #timekeyUseUTC: Timekey 使用 UTC s3: #keyId: Key Id #secretKey: Secret Key #endpoint: Endpoint #bucket: Bucket path: 路径 overwriteExistingPath: 覆盖现有的路径 output: selectOutputs: 选择输出 selectBanner: 选择以配置输出 sections: target: 目标 access: 访问 certificate: SSL 证书 labels: 标签 #outputProviders: #elasticsearch: Elasticsearch #splunkHec: Splunk #kafka: Kafka #forward: Fluentd #loki: Loki #awsElasticsearch: Amazon Elasticsearch #azurestorage: Azure Storage #cloudwatch: Cloudwatch #datadog: Datadog #file: File #gcs: GCS #kinesisStream: Kinesis Stream #logdna: LogDNA #logz: LogZ #newrelic: New Relic #sumologic: SumoLogic #syslog: Syslog #s3: S3 unknown: 未知类型 overview: #poweredBy: Banzai Cloud clusterLevel: 集群级别 namespaceLevel: 命名空间级别 provider: 提供商 splunk: host: splunk 主机 port: 端口 protocol: 协议 #index: Index token: 令牌 insecureSsl: 不安全的SSL #indexName: Index Name #source: Source caFile: CA 文件 caPath: CA 路径(目录) clientCert: 客户端证书 clientKey: 客户端密钥 forward: host: 主机 port: 端口 sharedKey: 共享密钥 username: 用户名 password: 密码 clientCertPath: 客户端证书路径 clientPrivateKeyPath: 客户端私钥路径 clientPrivateKeyPassphrase: 客户端私钥密码 longhorn: overview: title: 概述 subtitle: "由Longhorn提供支持" linkedList: longhorn: #label: 'Longhorn' description: '通过 UI 管理存储系统' na: 资源不可用 login: howdy: 您好! welcome: 欢迎使用 {vendor} loggedOut: 您已登出当前账号。 loginAgain: 请重新登录。 error: 登录时发生错误,请重试。 useLocal: 使用Local User账户登录 loginWithProvider: 使用 {provider} 登录 username: 用户名 password: 密码 loggingIn: 登录中... loggedIn: 已登录 loginWithLocal: 使用Local User账户登录 useProvider: 使用 {provider} 登录 monitoring: accessModes: many: 多次读写 once: 一次读写 readOnlyMany: 多次只读 aggregateDefaultRoles: label: 聚合为默认 Kubernetes 角色 tip: '将标签添加到监控图部署的ClusterRoles上,以聚合到相应的默认k8s管理、编辑和查看ClusterRoles。。' alerting: config: label: 配置告警管理 enable: label: 部署 Alertmanager secrets: additional: info: "密文应挂载到容器路径
    /etc/alertmanager/secrets/
    。" label: 附加密文 existing: 选择现有的配置密文 info: | 创建默认配置。在部署这个 chart 时,将在
    cattle-monitoring-system
    命名空间中创建一个包含 Alertmanager 配置的密钥,名称为
    alertmanager-rancher-monitoring-alertmanager
    。默认情况下,在卸载或升级此图表时,此 Secret 将永远不会被修改。
    一旦您部署了这个 chart,您应该通过用户界面编辑密钥,以便添加您的自定义通知配置,这些配置将被 Alertmanager 用于发送警报。

    选择一个现有的配置密钥:您必须指定一个存在于
    cattle-monitoring-system
    命名空间中的密钥。如果命名空间不存在,您将无法选择一个现有的密钥。 label: Alertmanager 密文 new: 创建默认配置 radio: label: 配置密文 templates: keyLabel: 文件名称 label: 模板文件 valueLabel: YAML 模板 title: 配置 Alertmanager clusterType: label: 集群类型 placeholder: 选择集群类型 createDefaultRoles: label: 创建默认 Monitoring 集群角色 tip: '创建 monitoring-adminmonitoring-edit,和 monitor-view ClusterRoles,可以被分配给用户,为部署监控 Chart 安装 CRDs 提供权限。' etcdNodeDirectory: label: ETCD 节点证书目录 tooltip: '对于使用 RancherOS 作为 etcd 节点的集群,这个选项应该设置为
    /opt/rke/etc/kubernetes/ssl
    。不支持需要指定多个证书目录的混合环境(例如,由 RancherOS 和 Ubuntu 主机组成的 etcd 平面)。' grafana: storage: annotations: PVC 注释 className: 存储类名称 existingClaim: 使用已有的 Claim #finalizers: PVC Finalizers label: Grafana 的持久存储 mode: 访问模式 selector: 选择器 size: 大小 subpath: 使用子路径 type: 持久存储类型 types: existing: 使用已有的 PVC 启用 Grafana statefulset: 使用 StatefulSet 模板启用 Grafana template: 使用 PVC 模板启用 Grafana volumeMode: 存储卷模式 volumeName: 存储卷名称 title: 配置 Grafana overview: alertsList: ends: label: 停止于 label: 已启用的告警 message: label: 信息 severity: label: 严重程度 start: label: 开始于 linkedList: alertManager: description: 已启用的告警 #label: Alertmanager grafana: description: Metrics 仪表盘 #label: Grafana na: 资源不可用 prometheusPromQl: description: PromQL 图表 label: Prometheus 图表 prometheusRules: description: 配置规则 label: Prometheus 规则 prometheusTargets: description: 配置目标 #label: Prometheus Targets subtitle: '由Prometheus提供支持' title: 仪表盘 v1Warning: '当前监控由 Rancher UI 部署,如果你想在仪表盘中启用新的监控,请先在 Rancher UI 中禁用原来的监控。' prometheus: config: #adminApi: Admin API evaluation: 评估时间间隔 ignoreNamespaceSelectors: help: '忽略命名空间选择器允许集群管理员限制团队查看他们有权监视的命名空间之外的资源,但这会破坏应用程序的功能,这些应用程序依赖于设置跨多个命名空间捕获目标监控数据,比如 Istio。' label: 命名空间选择器 radio: enforced: '使用: 监控可以基于与命名空间选择器字段匹配的命名空间访问资源' ignored: '忽略: 监控只能访问它们所在命名空间中的资源' limits: cpu: CPU 限制 memory: Memory 限制 requests: cpu: CPU 预留 memory: Memory 预留 resourceLimits: 资源限制 retention: 预留 retentionSize: 预留大小 scrape: 刮擦间隔(prometheus 获取数据间隔) storage: className: 存储类名称 label: Prometheus 持久存储 mode: 访问模式 selector: 选择器 selectorWarning: '如果你正在使用一个动态配置器(例如 Longhorn),不应该指定选择器,因为带有非空选择器的PVC不能动态配置PV。' size: 大小 volumeMode: Volume 模式 volumeName: Volume 名称 title: 配置 Prometheus warningInstalled: | '警告:目前已经部署了Prometheus Operators。目前不支持在一个集群上部署多个Prometheus Operators。在尝试安装此chart之前,请从该集群中移除所有其他的普罗米修斯Operators部署。 如果您是从启用了监控功能的旧版Rancher迁移过来的,请在尝试安装此chart之前完全禁用此集群上的监控功能。 receiver: fields: name: 名称 tls: #label: SSL caFilePath: label: CA 文件路径 placeholder: 例如:./ca-file.csr certFilePath: label: 证书文件路径 placeholder: 例如:./cert-file.crt keyFilePath: label: 密钥文件路径 placeholder: 例如:./key-file.pfx secretsBanner: 当部署监控图表时,必须在
    alertmanager.alertmanagerSpec.secrets
    中引用以下文件路径。 route: fields: groupBy: Group By groupInterval: 组间隔 groupWait: 组等待 receiver: 接收者 repeatInterval: 重复间隔 tabs: alerting: 告警 general: 总体 #grafana: Grafana #prometheus: Prometheus v1Warning: '当前监控由 Rancher UI 部署,如果你想在仪表盘中启用新的监控,请先在 Rancher UI 中禁用原来的监控。' volume: modes: block: 块 file: 文件系统 monitoringReceiver: addButton: 添加 {type} custom: label: 自定义 title: 自定义参数 info: 这里提供的YAML将直接附加到Alertmanager的接收器的配置密钥中。 email: label: 电子邮箱 title: 电子邮箱参数 opsgenie: #label: Opsgenie title: Opsgenie参数 pagerduty: #label: PagerDuty title: PagerDuty参数 info: "你可以找到更多关于为PagerDuty创建集成密钥的信息这里。" slack: label: Slack title: Slack参数 info: "您可以在这里找到有关为Slack创建传入Webhooks的其他信息。" webhook: #label: Webhook title: Webhook参数 urlTooltip: 对于一些webhooks来说,这是一个指向DNS服务的url modifyNamespace: 如果
    rancher-alerting-drivers
    被安装在一个非默认的命名空间中,你需要更新下面网址中的命名空间。 banner: 要使用Microsoft Teams或阿里巴巴云短信,你需要先安装
    rancher-alerting-drivers
    。 add: generic: 通用 #msTeams: Microsoft Teams alibabaCloudSms: 阿里巴巴云短信 auth: label: 认证 authType: 认证类型 username: 用户名 password: 密码 none: label: 无 bearerToken: #label: Bearer Token placeholder: 例如:secret-token #basicAuth: #label: Basic Auth bearerTokenFile: #label: Bearer Token File placeholder: 例如:./user_token shared: proxyUrl: label: 代理URL placeholder: 例如:http://my-proxy/ sendResolved: label: 启用发送已解决的警报 monitoringRoute: groups: label: 分组 info: 这是 Alertmanager 使用的默认通知,作为与任何其他路由不匹配的警报的默认目的地。此通知必须存在,不能删除。 interval: label: 组间隔 matching: info: 根路由必须匹配所有内容,因此无法配置匹配。 label: 匹配 receiver: label: 接收者 regex: label: 匹配正则表达式 repeatInterval: label: 重复间隔 wait: label: 组等待时长 nameNsDescription: name: label: 名称 placeholder: '请输入名称' namespace: label: 命名空间 #placeholder: workspace: label: 工作空间 #placeholder: description: label: 描述 placeholder: 请输入一些能更好地描述该资源的文字 namespace: containerResourceLimit: 容器资源限制 project: label: 项目 resources: 资源 enableAutoInjection: 启用Istio自动注入 disableAutoInjection: 禁用Istio自动注入 namespaceFilter: selected: label: "{total} 项目选择" namespaceList: selectLabel: 命名空间 addLabel: 添加命名空间 node: detail: detailTop: containerRuntime: 容器运行时 internalIP: 内部IP地址 externalIP: 外部IP地址 #os: OS version: 版本 glance: consumptionGauge: used: 已使用 amount: "已使用{total} {unit}中的{used}" cpu: CPU memory: 内存 pods: PODS diskPressure: 磁盘压力 kubelet: kubelet memoryPressure: 内存压力 pidPressure: PID 压力 tab: conditions: 状态 images: 镜像 info: label: 信息 key: architecture: 架构 bootID: Boot ID containerRuntimeVersion: Container Runtime 版本 kernelVersion: Kernel 版本 kubeProxyVersion: Kube Proxy 版本 kubeletVersion: Kubelet 版本 machineID: 机器 ID operatingSystem: 操作系统 osImage: 镜像 systemUUID: System UUID pods: Pods taints: 污点 persistentVolume: pluginConfiguration: label: 插件配置信息 customize: label: 自定义 affinity: #label: Node Selectors addLabel: 添加 Node Selector assignToStorageClass: label: 分配给存储类 mountOptions: label: 挂载选项 addLabel: 添加选项 accessModes: label: 访问模式 readWriteOnce: 单节点读写 readOnlyMany: 多节点只读 readWriteMany: 多节点读写 shared: partition: label: 分区 placeholder: 例如:1; 0 readOnly: label: 只读 filesystemType: label: 文件系统类型 placeholder: 例如:ext4 secretName: label: 密钥名称 placeholder: 例如:secret secretNamespace: label: 密钥命名空间 placeholder: 例如:default monitors: add: 添加监控 vsphereVolume: #label: VMWare vSphere 卷 volumePath: label: 卷路径 placeholder: 例如:/ storagePolicyName: label: 存储策略名称 placeholder: 例如:sp storagePolicyId: label: 存储策略ID placeholder: 例如:sp1 csi: label: CSI(不支持) driver: label: 驱动 placeholder: 例如:driver.longhorn.io volumeHandle: #label: Volume Handle placeholder: 例如:pvc-xxxx volumeAttributes: add: 添加卷参数 nodePublishSecretName: #label: Node Publish Secret Name placeholder: 例如:secret nodePublishSecretNamespace: #label: Node Publish Secret Namespace placeholder: 例如:default nodeStageSecretName: #label: Node Stage Secret Name placeholder: 例如:secret nodeStageSecretNamespace: #label: Node Stage Secret Namespace placeholder: 例如:default controllerExpandSecretName: #label: Controller Expand Secret Name placeholder: 例如:secret controllerExpandSecretNamespace: #label: Controller Expand Secret Namespace placeholder: 例如:default controllerPublishSecretName: #label: Controller Publish Secret Name placeholder: 例如:secret controllerPublishSecretNamespace: #label: Controller Publish Secret Namespace placeholder: 例如:default cephfs: label: Ceph Filesystem(不支持) path: label: 路径 placeholder: 例如:/var user: label: 用户 placeholder: 例如:root secretFile: label: 密钥文件 placeholder: 例如:secret rbd: label: Ceph RBD(不支持) user: label: 用户 placeholder: 例如:root keyRing: #label: Key Ring placeholder: 例如:/etc/ceph/keyring pool: #label: Pool placeholder: 例如:rbd image: label: 镜像 placeholder: 例如:image fc: label: Fibre Channel(不支持) targetWWNS: add: 添加模板WWN wwids: add: 添加 WWID lun: #label: Lun placeholder: 例如:2 flexVolume: label: Flex Volume(不支持) driver: label: 驱动 placeholder: 例如:driver options: add: 添加选项 flocker: label: Flocker(不支持) datasetName: label: 数据集名称 placeholder: 例如:dataset datasetUUID: label: 数据集 UUID placeholder: 例如:uuid glusterfs: label: Gluster Volume(不支持) endpoints: label: Endpoints placeholder: 例如:glusterfs-cluster path: label: 路径 placeholder: 例如:kube-vol iscsi: label: iSCSI Target(不支持) initiatorName: #label: Initiator Name #placeholder: iqn.1994-05.com.redhat:1df7a24fcb92 iscsiInterface: #label: iSCSI Interface placeholder: 例如:interface chapAuthDiscovery: #label: Chap Auth Discovery chapAuthSession: #label: Chap Auth Session iqn: #label: IQN #placeholder: iqn.2001-04.com.example:storage.kube.sys1.xyz lun: #label: Lun placeholder: 例如:2 targetPortal: label: 模板Portal placeholder: 例如:portal portals: add: 添加Portal cinder: label: Openstack Cinder Volume(不支持) volumeId: #label: Volume ID placeholder: 例如:vol quobyte: label: Quobyte Volume(不支持) volume: label: Volume placeholder: 例如:vol user: label: 用户名 placeholder: 例如:root group: label: 用户组 placeholder: 例如:abc registry: label: 仓库 placeholder: 例如:abc photonPersistentDisk: label: Photon Volume(不支持) pdId: #label: PD ID placeholder: 例如:abc portworxVolume: label: Portworx Volume(不支持) volumeId: #label: Volume ID placeholder: 例如:abc scaleIO: label: ScaleIO Volume(不支持) volumeName: #label: Volume Name placeholder: 例如:vol-0 gateway: #label: Gateway placeholder: 例如:https://localhost:443/api protectionDomain: #label: Protection Domain placeholder: 例如:pd01 storageMode: #label: Storage Mode placeholder: 例如:ThinProvisioned storagePool: label: 存储池 placeholder: 例如:sp01 system: label: 系统 placeholder: 例如:scaleio sslEnabled: label: 启用SSL storageos: label: StorageOS(不支持) volumeName: label: 卷名称 placeholder: 例如:vol volumeNamespace: label: 卷命名空间 placeholder: 例如:default nfs: #label: NFS Share path: label: 路径 placeholder: 例如:/var server: label: Server IP 地址 placeholder: 例如:10.244.1.4 longhorn: #label: Longhorn volumeHandle: #label: Volume Handle placeholder: 例如:pvc-xxxx options: label: 选项 addLabel: 添加 local: label: 本地 path: label: 路径 placeholder: 例如:/mnt/disks/ssd1 hostPath: label: 主机路径 pathOnTheNode: label: 节点上的路径 placeholder: 例如:/mnt/disks/ssd1 mustBe: label: 节点上的路径必须是: anything: '任意路径:不需要检查目标路径' directory: 一个文件夹,如果该文件夹不存在,则自动创建一个文件夹 file: 一个文件,如果该文件不存在,则自动创建一个文件 existingDirectory: 一个已有的文件夹 existingFile: 一个已有的文件 existingSocket: 一个已有的socket existingCharacter: 一个已有的character device existingBlock: 一个已有的block device gcePersistentDisk: #label: Google Persistent Disk persistentDiskName: label: Disk 名称 placeholder: 例如:abc awsElasticBlockStore: #label: Amazon EBS Disk volumeId: label: 卷ID placeholder: 例如:volume1 azureFile: #label: Azure Filesystem shareName: label: Share名称 placeholder: 例如:abc azureDisk: #label: Azure Disk diskName: label: Disk名称 placeholder: 例如:kubernetes-pvc diskURI: #label: Disk URI placeholder: 例如:https://example.com/disk kind: label: 类型 dedicated: 专用 managed: 管理 shared: 共享 cachingMode: label: 缓存模式 none: 无 readOnly: 只读 readWrite: 读写 filesystemType: label: 文件系统类型 placeholder: 例如:ext4 readOnly: label: 只读 persistentVolumeClaim: accessModes: 访问模式 capacity: 容量 storageClass: 存储类 useDefault: 使用默认存储类 volumes: 持久卷 volumeName: 持久卷名称 source: label: 资源 options: new: 使用存储类创建新的持久卷(PV) existing: 使用已有的持久卷(PV) volumeClaim: label: 卷声明 storageClass: 存储类 requestStorage: 需要的存储大小 persistentVolume: 持久卷 customize: label: 自定义 accessModes: readWriteOnce: 单节点读写 readOnlyMany: 多节点只读 readWriteMany: 多节点读写 status: label: 状态 prefs: title: 用户偏好设置 theme: label: 主题 light: 浅色 auto: 自动 dark: 深色 autoDetail: 选择自动设置,将会在晚 6 点到次日早 6 点间自动切换到黑色主题。 landing: label: 默认登录页面 vue: 仪表盘 ember: Rancher UI formatting: 格式 dateFormat: label: 日期格式 timeFormat: label: 时间格式 perPage: label: 每页行数 value: |- {count, number} keymap: label: YAML 编辑器选择 sublime: '默认' emacs: 'Emacs' vim: 'Vim' advanced: 高级选项 dev: label: 启用开发工具 hideDesc: label: 隐藏所有类型说明框 helm: 'true': 包括预发布的版本 'false': 只显示正式发布的版本 #label: Helm Charts principal: loading: 加载中… error: 无法获取信息 name: 名称 loginName: 用户名 type: 类型 probe: checkInterval: label: 检查间隔 placeholder: '默认值是10秒' command: label: 运行命令 placeholder: 例如:cat /tmp/health failureThreshold: label: 失败阈值 placeholder: '默认值是3次' httpGet: headers: label: 请求头 path: label: 请求路径(Path) placeholder: 例如:/healthz port: label: 检查端口 placeholder: 例如:80 placeholderDuex: 例如:25 initialDelay: label: 初始延迟 placeholder: '默认值是' successThreshold: label: 成功阈值 placeholder: '默认值是1' timeout: label: 超时 placeholder: '默认值是3' type: label: 检测类型 placeholder: 选择检查类型 prometheusRule: alertingRules: addLabel: 添加告警 annotations: description: input: 描述注释值 label: 描述 label: 注释 message: input: 消息注释值 label: 消息 runbook: #input: Runbook URL Annotation Value #label: Runbook URL #summary: #input: Summary Annotation Value #label: Summary bannerText: '在触发告警时,注释和标签将被传递给配置的 alertmanager,以允许它们构造通知信息并发送给配置的接收者。' for: label: 告警触发等待时间 #placeholder: '60' label: 高级规则 labels: label: 标签 severity: choices: critical: 重要 label: 严重性标签值 none: none warning: 警告 label: 严重程度 name: 告警名称 removeAlert: 删除告警 groups: add: 添加规则组 groupRowLabel: 规则组 {index} groupInterval: label: 覆盖组间隔 placeholder: '60' label: 规则组 name: 组名称 none: 请添加至少一个规则组,其中至少包含一个警告或一个记录规则。 removeGroup: 删除组 responseStrategy: label: 部分响应策略 promQL: label: PromQL 表达式 recordingRules: addLabel: 添加记录 label: 记录规则 labels: 标签 name: 时间序列的名称 removeRecord: 删除记录 promptRemove: andOthers: |- {count, plural, =0 {.} =1 {,还有另一个} other {, 还有其他{count}个} } attemptingToRemove: "您在尝试删除 {type}" protip: "提示:按住 {alternateLabel} 键同时单击 delete 以绕过此确认" confirmName: "Enter {nameToMatch} below to confirm:" rancherAlertingDrivers: msTeams: 启用Microsoft Teams通知 sms: 启用短信通知 selectOne: 你必须选择以下至少一个选项。 rbac: roleBinding: noData: 没有与此资源相关联的成员。 user: label: 用户 role: label: 角色 add: 添加成员 displayRole: fleetworkspace-admin: 管理员 fleetworkspace-member: 成员 fleetworkspace-readonly: 只读用户 roletemplate: label: 角色 newUserDefault: no: 否 tooltip: 这并不影响任何已经存在的角色的绑定。 locked: label: 锁定 yes: '是:新的绑定不允许使用这个角色' no: 否 tabs: grantResources: label: 授予资源 tableHeaders: verbs: 操作 resources: 资源 nonResourceUrls: 非资源URL apiGroups: API组 subtypes: GLOBAL: createButton: 创建全局角色 label: 全局 yes: "是:新用户的默认角色" defaultLabel: 新用户的默认角色 CLUSTER: createButton: 创建集群角色 label: 集群 yes: "是:创建新集群的默认角色" defaultLabel: 集群创建者 NAMESPACE: createButton: 创建项目或命名空间角色 label: 项目或命名空间 yes: "是:创建项目或命名空间的默认角色" defaultLabel: 项目创建者 RBAC_ROLE: label: 角色 RBAC_CLUSTER_ROLE: label: 集群角色 noContext: label: 没有内容 globalRoles: types: global: label: 全局权限 description: |- 控制{isUser, select, true {user} false {group}}有什么权限来管理整个{appName}的安装。 custom: label: 自定义 description: 不是Rancher创建的角色 builtin: label: 内置角色 description: 额外的角色来定义更搞细粒度的权限模型。 unknownRole: description: 无描述 assignOnlyRole: 已分配该角色 role: admin: label: 管理员 description: 管理员可以完全控制整个安装和所有集群中的所有资源。 restricted-admin: label: 受限管理员 description: 受限管理员可以完全控制所有下游集群的所有资源,但不能访问本地集群。 user: label: 普通用户 description: 普通用户可以创建新的集群并管理他们被授予访问权的集群和项目。 user-base: label: User-Base 用户 description: User-Base 用户只拥有登录权限。 clusters-create: label: 创建集群 description: 允许用户创建集群,并成为该集群的所有者(owner)。 clustertemplates-create: label: 创建RKE集群模板 description: 允许用户创建RKE集群模板,并成为该模板的所有者(owner)。 authn-manage: label: 配置认证方式 description: 运行用户启用、编辑或禁用所有的认证方式。 catalogs-manage: label: 配置应用 description: 允许用户添加、编辑和删除应用。 clusters-manage: label: 管理所有集群 description: 允许用户管理所有集群,包括他们不是成员的集群。 clusterscans-manage: label: 管理CIS集群扫描 description: 允许用户运行新建的CIS集群扫描和管理现有的CIS集群扫描。 kontainerdrivers-manage: label: 创建集群驱动 description: 允许用户新建集群驱动,并成为该集群驱动的所有者(owner)。 features-manage: label: 配置功能标记 description: 允许用户通过功能标志设置来启用和禁用自定义功能。 nodedrivers-manage: label: 配置集群驱动 description: 允许用户启用、配置和删除所有节点驱动设置。 nodetemplates-manage: label: 管理节点模板 description: 允许用户定义、编辑和删除节点模板。 podsecuritypolicytemplates-manage: label: 管理Pod安全策略(PSP) description: 允许用户定义、编辑和删除Pod安全策略。 roles-manage: label: 管理用户角色 description: 允许用户定义、编辑和删除用户角色。 settings-manage: label: 管理Rancher配置 description: 允许用户管理Rancher配置。 users-manage: label: 管理用户 description: 允许用户为所有用户创建、删除和设置密码。 catalogs-use: label: 使用应用 description: 允许用户查看和部署应用中的模板。 普通用户默认拥有此权限。 nodetemplates-use: label: 使用节点模板 description: 允许用户使用任何现有的节点模板来部署新的节点。 view-rancher-metrics: label: 查看Rancher指标 description: 允许用户通过API查看Metrics。 base: label: 登录权限 resourceDetail: detailTop: annotations: 注释 created: 已创建 deleted: 已删除 description: 描述 labels: 标签 ownerReferences: |- {count, plural, =1 {Owner} other {Owners}} hideAnnotations: |- {annotations, plural, =1 {Hide 1 annotation} other {Hide {annotations} annotations}} showAnnotations: |- {annotations, plural, =1 {Show 1 annotation} other {Show {annotations} annotations}} name: 名称 header: clone: "从 {subtype} {name} 克隆" create: 创建 {subtype} edit: "{subtype} {name}" stage: "Stage from {subtype} {name}" view: "{subtype} {name}" masthead: #age: Age defaultBannerMessage: error: 此资源当前处于错误状态,但没有可用的详细消息。 transitioning: 此资源当前处于转换状态,但没有可用的详细消息。 sensitive: hide: 隐藏敏感信息 show: 显示敏感信息 namespace: 命名空间 workspace: 工作空间 project: 项目 detail: 详情 config: 配置 #yaml: YAML managedWarning: |- This {type} is managed by {hasName, select, no {a {managedBy} app} yes {the {managedBy} app {appName}}}; 在此所做的更改可能会在应用程序下次更改时被覆盖。 resourceList: head: create: 创建 createFromYaml: 使用 YAML 文件创建 createResource: "创建 {resourceName}" resourceTable: groupBy: none: 平面列表 namespace: 以命名空间分组 project: 以项目分组 groupLabel: cluster: "集群: {name}" namespace: "命名空间: {name}" machinePool: "节点池 {name}" notInANamespace: 不在命名空间内 notInAProject: 不在项目内 project: "项目: {name}" notInAWorkspace: 不在工作空间内 workspace: "工作空间: {name}" resourceTabs: conditions: tab: 条件 events: tab: 最近事件 related: tab: 相关资源 #from: Referred To By #to: Refers To resourceYaml: errors: namespaceRequired: 这个资源是有命名空间的,所以必须提供一个命名空间。 buttons: continue: 继续编辑 diff: 显示差异 rioConfig: configure: description: 描述 helpText: listItem1: Kubernetes 的应用部署引擎 listItem2: "Rio 使 DevOps 更快、更容易地构建、测试、部署、扩展和版本无状态应用。" requirements: header: 主机要求 helpText: listItem1: 至少 1 核心 CPU listItem2: 至少 2 GB 内存 #header: Rio yaml: buttonText: 自定义 secret: authentication: 身份验证 certificate: certificate: 证书 cn: 域名 expires: 到期 issuer: Issuer plusMore: "+ {n} 更多" privateKey: 私钥 data: 数据 registry: address: 仓库类型 domainName: 仓库地址 password: 密码 username: 用户名 basic: password: 密码 username: 用户名 ssh: keys: Keys public: 公钥 private: 私钥 serviceAcct: ca: CA 证书 token: Token type: 类型 types: #'opaque': 'Opaque' #'kubernetes.io/service-account-token': 'Svc Acct Token' 'kubernetes.io/dockercfg': '仓库' 'kubernetes.io/dockerconfigjson': '仓库' 'kubernetes.io/basic-auth': 'HTTP Basic Auth' 'kubernetes.io/ssh-auth': 'SSH 密钥' 'kubernetes.io/tls': 'TLS 证书' 'bootstrap.kubernetes.io/token': 'Bootstrap Token' 'istio.io/key-and-cert': 'Istio 证书' 'helm.sh/release.v1': 'Helm 版本' 'fleet.cattle.io/cluster-registration-values': 'Fleet 集群' 'provisioning.cattle.io/cloud-credential': '云凭证' initials: 'opaque': 'O' 'kubernetes.io/service-account-token': 'SAT' 'kubernetes.io/dockercfg': 'R' 'kubernetes.io/dockerconfigjson': 'R' 'kubernetes.io/basic-auth': 'HTTP' 'kubernetes.io/ssh-auth': 'SSH' 'kubernetes.io/tls': 'TLS' 'bootstrap.kubernetes.io/token': 'Boot' 'istio.io/key-and-cert': 'Ist' 'helm.sh/release.v1': 'Helm' 'fleet.cattle.io/cluster-registration-values': 'F' 'provisioning.cattle.io/cloud-credential': 'CC' relatedWorkloads: 相关的工作负载 selectOrCreateAuthSecret: label: 认证 options: none: 无 basic: HTTP Basic Auth ssh: SSH 密钥 custom: 密钥名称 ssh: publicKey: 公钥 privateKey: 私钥 basic: username: 用户名 password: 密码 namespaceGroup: "命名空间:{name}" chooseExisting: "选择一个已有的密钥" createSsh: 创建一个新的SSH密钥对 createBasic: 创建一个新的HTTP Basic Auth 密钥 servicePorts: header: label: 端口规则 rules: listening: label: 监听端口 placeholder: 例如:8080 name: label: 端口名称 placeholder: 例如:myport node: label: 节点端口 placeholder: 例如:80 protocol: label: 协议 target: label: 目标端口 placeholder: 例如:80 或 http serviceTypes: clusterip: 集群 IP 地址 externalname: 外部 DNS 名称 headless: Headless loadbalancer: 负载均衡 nodeport: 节点端口 servicesPage: anyNode: 任何节点 labelsAnnotations: label: 标签和注释信息 affinity: actionLabels: clientIp: 客户端 IP none: 未配置会话保持 helpText: 根据其源 IP 将连接映射到一个一致的目标 label: 会话保持 timeout: label: 会话粘滞时间 placeholder: 以秒为单位,例如 10800 表示 10800 秒,即 48 分钟 externalName: define: DNS 名称 helpText: "外部名称的目的是指定一个 DNS 名称。如果要硬编码一个 IP 地址,请使用 headless 服务。" label: 外部 DNS 服务名称 placeholder: 例如:my.database.example.com input: label: DNS名称 ips: define: 定义服务端口 clusterIpHelpText: Cluster IP 地址必须在为 API 服务器配置的 CIDR 范围内。 external: label: 外部 IP placeholder: 例如:1.1.1.1 protip: 集群中哪些节点也将接受该服务的流量的 IP 地址列表 input: label: 集群 IP placeholder: 例如:10.43.XXX.XXX label: 监听 IP pods: #label: Pods ports: label: 端口 selectors: helpText: "如果没有创建选择器,则必须手动输入端点。" label: 选择器 matchingPods: matchesSome: |- {matched, plural, =0 {与{total, number}个pods中的0个匹配。如果没有创建选择器,必须进行手动端点。} =1 {与{total, number}个pods中的1个匹配: "{sample}"} other {与{total, number}个pods中的{matched, number}个匹配,包括 "{sample}"。} } serviceTypes: clusterIp: abbrv: IP description: 在集群内部 IP 上公开服务。选择此值使服务只能从集群内部访问。这是默认类型。 label: 集群 IP externalName: abbrv: EN description: "将服务与`externalName`字段的内容(如 foo.bar.example.com)进行映射,返回一个带有其值的 CNAME 记录。没有设置任何形式的代理。" label: 外部 DNS 服务名称 headless: abbrv: H description: 既没有定义集群 IP,也没有定义负载均衡器。这些是用来与 Kubernetes 实现之外的其他服务发现机制对接的。没有分配集群 IP,kube-proxy 也不处理这些服务。 label: Headless loadBalancer: abbrv: LB description: 使用云提供商的负载平衡器向外部暴露服务。 label: 负载均衡器 nodePort: abbrv: NP description: "在每个节点的 IP 上以静态端口(`NodePort`)公开服务。您将能够通过请求`:`从集群外部联系这种类型的服务。" label: 节点端口 typeOpts: label: 服务类型 sortableTable: actionAvailability: selected: "已选择 {actionable} 项" some: "一共有 {total} 项,符合条件的有 {actionable} 项" noData: 没有匹配项 noRows: 没有内容显示 noActions: 没有可用的操作 paging: generic: |- {pages, plural, =0 {无项目} =1 {{count}项} other {{count}项中的第{from} - {to}项}} resource: |- {pages, plural, =0 {No {pluralLabel}} =1 {{count} {count, plural, =1 {{singularLabel}} other {{pluralLabel}}}} other {{from} - {to} of {count} {pluralLabel}}} search: Filter storageClass: actions: setAsDefault: 设置为默认配置 resetDefault: 重设默认配置 parameters: label: 参数 customize: label: 自定义 reclaimPolicy: label: 回收策略 delete: 删除存储卷时,同时删除卷和底层设备。 retain: 保留存储卷,以通过手动清理。 allowVolumeExpansion: label: 允许扩展存储卷 enabled: 允许 disabled: 不允许 volumeBindingMode: label: 存储卷卷绑定模式 now: 在创建PersistentVolumeClaim时,立即绑定并配置一个持久卷 later: 创建了使用PersistentVolumeClaim的Pod之后,再绑定并配置一个持久卷。 mountOptions: label: 挂载存储卷选项 addlabel: 添加选项 aws-ebs: title: Amazon EBS磁盘 volumeType: label: 存储卷类型 #gp2: GP2 - General Purpose SSD #io1: IO1 - Provisioned IOPS SSD #st1: ST1 - Throughput-Optimized HDD #sc1: SC1 - Cold-Storage HDD provisionedIops: #label: Provisioned IOPS suffix: 每秒,每GB filesystemType: label: 文件系统类型 placeholder: 例如:ext4 availabilityZone: label: 可用区 automatic: '自动选择:选择节点所在区域作为可用区' manual: '手动选择:自行指定一个可用区' placeholder: 例如:us-east-1d, us-east-1c encryption: label: 加密 enabled: 启用 disabled: 不启用 keyId: label: 用于加密的KMS密钥ID automatic: '自动:生成一个密钥' manual: '手动:使用一个指定的密钥(full ARN)' azure-disk: title: Microsoft Azure磁盘 storageAccountType: label: Storage Account类型 placeholder: 例如:Standard_LRS kind: label: 类型 shared: 共享 (unmanaged disk) dedicated: 独享 (unmanaged disk) managed: 管理 azure-file: title: Azure文件 skuName: label: Sku名称 placeholder: 例如:Standard_LRS location: label: 位置 placeholder: 例如:eastus storageAccount: #label: Storage Account placeholder: 例如:azure_storage_account_name gce-pd: title: Google Persistent磁盘 volumeType: label: 存储卷类型 standard: 标准 ssd: SSD filesystemType: label: 文件系统类型 placeholder: 例如:ext4 availabilityZone: label: 可用区 automatic: '自动选择:选择节点所在区域作为可用区' manual: '手动选择:自行指定一个可用区' placeholder: 例如:us-east-1d和us-east-1c replicationType: label: 副本类型 zonal: 可用区 regional: 区域 longhorn: #title: Longhorn addLabel: 添加参数 vsphere-volume: title: VMWare vSphere卷 diskFormat: label: 磁盘格式 #thin: Thin #zeroedthick: Zeroed Thick #eagerzeroedthick: Eager Zeroed Thick storagePolicyName: label: 存储策略名称 placeholder: 例如:gold datastore: #label: Datastore placeholder: 例如:VSANDatastore hostFailuresToTolerate: label: 容忍主机失败的次数 placeholder: 例如:2 cacheReservation: label: 预留缓存的大小 placeholder: 例如:20 filesystemType: label: 文件系统类型 placeholder: 例如:ext3 custom: addLabel: 添加参数 glusterfs: title: Gluster Volume(不支持) restUrl: label: REST URL placeholder: 例如:http://127.0.0.1:8081 restUser: label: REST 用户 placeholder: 例如:admin restUserKey: label: REST 用户密钥 placeholder: 例如:password secretNamespace: label: 密钥所在的命名空间 placeholder: 例如:default secretName: label: 密钥名称 placeholder: 例如:heketi-secret clusterId: label: 集群ID placeholder: 例如:630372ccdc720a92c681fb928f27b53f gidMin: label: GID MIN placeholder: 例如:40000 gidMax: label: GID MAX placeholder: 例如:50000 volumeType: label: 卷类型 placeholder: 例如:eplicate:3 cinder: title: Openstack Cinder Volume(不支持) volumeType: label: 卷类型 placeholder: 例如:fast availabilityZone: label: 可用区 automatic: "自动选择:选择节点所在区域作为可用区" manual: label: "手动选择:自行指定一个可用区" placeholder: 例如:nova rbd: title: Ceph RBD(不支持) monitors: label: 监控 placeholder: 例如:10.16.153.105:6789 adminId: #label: Admin ID placeholder: 例如:kube adminSecretNamespace: label: Admin密钥所在的命名空间 placeholder: 例如:kube-system adminSecret: label: Admin密钥 placeholder: 例如:Secret pool: label: 池 placeholder: 例如:kube userId: label: 用户ID placeholder: 例如:kube userSecretNamespace: label: 用户密钥所在的命名空间 placeholder: 例如:default userSecretName: label: 用户密钥名称 placeholder: 例如:ceph-secret-user filesystemType: label: 文件系统类型 placeholder: 例如:ext4 imageFormat: label: 镜像格式 placeholder: 例如:2 imageFeatures: label: 镜像功能 placeholder: 例如:layering quobyte: title: Quobyte Volume (不支持) quobyteApiServer: label: Quobyte API Server placeholder: 例如:http://138.68.74.142:7860 registry: label: 仓库IP地址 placeholder: 例如:138.68.74.142:7861 adminSecretNamespace: label: Admin密钥所在的命名空间 placeholder: 例如:kube-system adminSecretName: label: Admin密钥名称 placeholder: 例如:quobyte-admin-secret user: label: 用户 placeholder: 例如:root group: label: 用户组 placeholder: 例如:root quobyteConfig: label: Quobyte配置 placeholder: 例如:BASE quobyteTenant: label: Quobyte租户角色 placeholder: 例如:DEFAULT portworx-volume: title: Portworx Volume(不支持) filesystem: label: 文件系统类型 placeholder: 例如:ext4 blockSize: label: 区块大小 placeholder: 例如:32 repl: label: Repl placeholder: 例如:1; 0 for entire device ioPriority: label: I/O优先级 placeholder: 例如:low snapshotsInterval: label: 快照间隔 placeholder: 例如:70 aggregationLevel: label: 聚合水平 placeholder: 例如:0 ephemeral: #label: Ephemeral placeholder: 例如:true scaleio: title: ScaleIO Volume(不支持) gateway: label: 网关 placeholder: 例如:https://192.168.99.200:443/api system: label: 系统 placeholder: 例如:scaleio protectionDomain: #label: Protection Domain placeholder: 例如:pd0 storagePool: label: 存储池 placeholder: 例如:sp1 storageMode: label: 存储模式 #thin: Thin Provisioned #thick: Thick Provisioned secretRef: #label: Secret Ref placeholder: 例如:sio-secret readOnly: label: 只读 filesystemType: label: 文件系统类型 placeholder: 例如:xfs storageos: title: StorageOS(不支持) pool: label: 池 placeholder: 例如:default description: label: 描述 placeholder: 例如:Kubernetes volume filesystemType: label: 文件系统类型 placeholder: 例如:ext4 adminSecretNamespace: label: Admin密钥所在的命名空间 placeholder: 例如:default adminSecretName: label: Admin密钥名称 placeholder: 例如:storageos-secret no-provisioner: title: 本地存储(不支持) tableHeaders: #accessKey: Access Key address: 地址 age: 存活时间 apiGroup: API 分组 authRoles: globalDefault: 新用户默认 clusterDefault: 集群创建者默认 projectDefault: 项目创建者默认 branch: 分支 builtIn: 内置 #bundlesReady: Bundles bundleDeploymentsReady: 部署 builtin: 内建 #chart: Chart clusterCreatorDefault: 默认集群创建者 #clusterFlow: Cluster Flow #clusterOutput: Cluster Output clusters: 集群 clustersReady: 就绪的集群 clusterGroups: 集群组 #commit: Commit condition: 状态 #customVerbs: Custom Verbs description: 描述 expires: 过期时间 providers: 配置提供商 #cpu: CPU date: 日期 default: 默认 destination: 目标 download: 下载 effect: 影响 endpoints: 端点 #flow: Flow gitRepos: Git 代码仓库 host: |- {count, plural, one { 主机 } other { 主机 } } image: 镜像 imageSize: 大小 ingressDefaultBackend: 默认 ingressTarget: 目标 internalExternalIp: 外网 IP 地址或内网 IP 地址 jobs: Jobs key: 密钥 keys: 数据 lastUpdated: 最后更新时间 lastSeen: 最后出现 loggingOutputProviders: Providers machines: 机器 matches: 匹配 maxKubernetesVersion: 最大 Kubernetes 版本 message: 信息 minKubernetesVersion: 最小 Kubernetes 版本 name: 名称 nameDisplay: 显示名称 nameUnlinked: 名称 namespace: 命名空间 namespaceName: 命名空间名称 namespaceNameUnlinked: 名称 node: 节点 nodeName: 节点名称 nodesReady: 就绪节点 #nodePort: Node Port object: 对象 output: 输出 p95: 95 百分位数 persistentVolumeSource: 持久卷源 podImages: 镜像 #pods: Pods port: 端口 protocol: 协议 provider: 提供商 publicPorts: 公有端口 ram: 内存(RAM) rbac: create: 创建 delete: 删除 get: 查询 list: 列表 patch: 修改 update: 更新 watch: 监控 ready: 就绪 reason: 原因 repo: Repo reposReady: 就绪的 Repo replicas: 副本数量 reqRate: 请求频率 resource: 资源 resources: 资源 restarts: 重启 rioImage: Rio 镜像 role: 角色 roles: 角色 scale: 比例 scope: 规模 selector: 选择器 simpleName: 名称 simpleScale: 比例 simpleType: 类型 started: 已开始 state: 状态 status: 状态 storage_class_provisioner: 提供者 subject: 主题 subType: 类型 success: 成功 summary: 概述 target: 目标 targetKind: 目标类型 targetPort: 目标端口 type: 类型 updated: 更新 upgrade: 升级 url: URL 地址 userDisplayName: 显示名称 userId: 用户 ID userStatus: 用户状态 username: 本地用户名 value: 值 version: 版本号 weight: 权重 target: router: label: 路由 placeholder: 选择路由 service: label: 服务(svc) placeholder: 选择服务 title: 目标 version: label: 版本 placeholder: 选择版本 user: detail: username: 用户名 globalPermissions: label: 全局权限 description: 管理影响整个安装的资源的权限 adminMessage: 该用户是一个管理员,拥有所有权限 tableHeaders: permission: 权限 clusterRoles: label: 集群角色 description: 授予一个用户在某个集群的角色 tableHeaders: cluster: 集群 projectRoles: label: 项目角色 description: 授予一个用户在某个项目的角色 tableHeaders: project: 项目 generic: tableHeaders: role: 角色 #granted: Granted edit: credentials: label: 凭证 username: label: 用户名 placeholder: 例如:jsmith exists: '用户名已被使用。请选择一个新的用户名' displayName: label: 显示名称 placeholder: 例如:John Smith userDescription: label: 描述 placeholder: 例如:This account is for John Smith list: errorRefreshingGroupMemberships: 刷新小组成员名单时出错,请重试 validation: arrayLength: between: '"{key}" 应该包含 {min} 至 {max} {max, plural, =1 {项} other {项}}' exactly: '"{key}" 应该包含 {count, plural, =1 {#项} other {#项}}' max: '"{key}" 应该包含最多 {count} {count, plural, =1 {项} other {项}}' min: '"{key}" 应该包含最少 {count} {count, plural, =1 {项} other {项}}' boolean: '"{key}" 必须是一个布尔值' chars: '"{key}" 包含 {count, plural, =1 {一个无效字符} other {#多个无效字符}}: {chars}' custom: missing: "{validatorName}不存在校验! 该校验是否存在于自定义校验中?名字的拼写是否正确?" dns: doubleHyphen: '"{key}" 不能包含两个或多个连续的连字符“-”' hostname: empty: '"{key}" 必须至少包含一个字符' emptyLabel: '"{key}" 不能包含两个连续的点“.”' endDot: '"{key}" 不能以点“.”结束' endHyphen: '"{key}" 不能以连字符“-”结束' startDot: '"{key}" 不能以点“.”开始' startHyphen: '"{key}" 不能以连字符“-”开始' startNumber: '"{key}" 不能以数字开始' tooLong: '"{key}" 的长度不能超过 {max} 个字符数量' tooLongLabel: '"{key}" 不能包含超过 {max} 字符的部分' label: emptyLabel: '"{key}" 不能为空' endHyphen: '"{key}" 不能以连字符“-”结束' startHyphen: '"{key}" 不能以连字符“-”开始' startNumber: '"{key}" 不能以数字开始' tooLongLabel: '"{key}" 的长度不能超过 {max} 个字符数量' flowOutput: global: 需要选择 "集群输出"。 both: 需要选择 "输出" 或 "集群输出"。 output: logdna: apiKey: 需要设置一个 "Api 密钥"。 invalidCron: 无效 cron 调度 k8s: identifier: emptyLabel: '"{key}" 不能为空' emptyPrefix: '"{key}" 不能为空' endLetter: '"{key}" 末位必须是字母或数字' startLetter: '"{key}" 首位必须是字母或数字' tooLongKey: '"{key}" 的长度不能超过 {max} 个字符数量' tooLongPrefix: '"{key}" 前缀不能超过 {max} 个字符数量' noSchema: 没有找到可以验证的模式 noType: 无类型可验证 number: between: '"{key}" 的长度必须在 {min} 和 {max} 之间' exactly: '"{key}" 的长度必须是 {val}' max: '"{key}" 的长度必须小于或等于 {val}' min: '"{key}" 的长度必须大于或等于 {val}' podAffinity: affinityTitle: Pod 亲和性 antiAffinityTitle: Pod 反亲和性 requiredDuringSchedulingIgnoredDuringExecution: 需要规则 preferredDuringSchedulingIgnoredDuringExecution: 优先规则 topologyKey: Rule [{index}] of {group} {rules} - 拓扑键是必需的。 matchExpressions: operator: Rule [{index}] of {group} {rules} - operator must be one of 'In', 'NotIn', 'Exists', 'DoesNotExist' valueMustBeEmpty: Rule [{index}] of {group} {rules} - value must be empty if operator is 'Exists' or 'DoesNotExist' valuesMustBeDefined: Rule [{index}] of {group} {rules} - value must be defined if operator is 'In' or 'NotIn' port: 端口号的取值范围是1到65535之间的任何数字。 prometheusRule: groups: required: 至少需要一个规则组。 singleAlert: 规则可以包含警告规则或记录规则,但不能同时包含两者。 valid: name: '规则组需要名称 {index}.' rule: alertName: '规则组{groupIndex}规则{ruleIndex}需要一个警报名称。 ' expr: '规则组{groupIndex}规则{ruleIndex}需要一个PromQL表达式' labels: '规则组{groupIndex}规则{ruleIndex}至少需要一个标签。建议使用严重程度作为标签。' recordName: '规则组{groupIndex}规则{ruleIndex}需要一个时间序列名称。' singleEntry: '在规则组{index}中至少需要一个警报规则或一个记录规则。' required: '"{key}"是必填项' requiredOrOverride: '"{key}" 是必须的或必须允许覆盖的' roleTemplate: roleTemplateRules: missingVerb: 你必须为每个资源授予指定至少一个动作 missingResource: 你必须为每个资源授予至少指定一个资源、非资源URL或API组 missingApiGroup: 你必须为每个资源授予指定一个API组 missingOneResource: 你必须为每个资源授予至少指定一个资源、非资源URL或API组 service: externalName: none: '使用外部 DNS 服务时,External Name 是必填项' ports: name: required: "端口规则 [{position}] - 端口名称是必填项" nodePort: requriedInt: "端口规则 [{position}] - 如果包含节点端口,则节点端口必须是整数值,例如:80" port: required: "端口规则 [{position}] - 端口是必填项" requriedInt: "端口规则 [{position}] - 如果包含端口,则端口必须是整数值,例如:80" targetPort: between: "端口规则 [{position}] - 目标端口的取值范围是: 1~65535" iana: "端口规则 [{position}] - 目标端口必须是 IANA 服务名称或整数值" ianaAt: "端口规则 [{position}] - 目标端口 " required: "端口规则 [{position}] - 目标端口是必填项" stringLength: between: '"{key}" 的长度必须在 {min} 和 {max} 之间 {max, plural, =1 {字符} other {字符}}' exactly: '"{key}" 的长度必须是 {count, plural, =1 {#字符} other {#字符}}' max: '"{key}" 的长度必须小于或等于 {count} {count, plural, =1 {字符} other {字符}}' min: '"{key}" 的长度必须大于或等于 {count} {count, plural, =1 {字符} other {字符}}' targets: missingProjectId: 一个目标必须选定一个项目。 monitoring: route: match: 必须选择至少一个匹配或匹配正则表达式 interval: '"{key}" 必须是以数字后跟单位(如 1h, 2m, 30s)的格式。' wizard: back: 返回 finish: 完成 next: 下一步 step: "步骤 {number}:" wm: connection: connected: 已连接 connecting: 正在连接… disconnected: 已断开连接 error: 错误 containerLogs: clear: 清除 containerName: "容器: {label}" download: 下载 follow: 回到底部 noData: 在当前范围内没有日志条目显示 noMatch: 没有符合当前过滤条件的数据 previous: 使用前一个容器 range: all: 全部 hours: |- {value, number} {value, plural, =1 {小时} other {小时} } label: 显示最后一个 lines: "{value, number}行" minutes: |- {value, number} {value, plural, =1 {分} other {分} } search: 过滤条件 timestamps: 显示时间戳 wrap: 自动换行 containerShell: clear: 清除 containerName: "容器:{label}" kubectlShell: #title: "Kubectl: {name}" workload: container: command: addEnvVar: 添加 args: 命令 (CMD) as: 作为 command: 入口 (Entrypoint) env: 环境变量 fromResource: key: label: 键 placeholder: "例如:metadata.labels['']" name: label: 变量名 placeholder: "例如:FOO" prefix: 前缀 source: label: 源 placeholder: 例如:my-container secret: 密文 configMap: 配置映射 containerName: 容器名称 type: 类型 value: label: 值 placeholder: 例如:bar #tty: TTY workingDir: 工作目录 stdin: 标准输入 containerName: 容器名称 healthCheck: checkInterval: 检查间隔 command: command: 运行命令 failureThreshold: 故障阈值 httpGet: headers: 请求头 path: 请求路径 port: 检查端口 initialDelay: 初始延迟 livenessProbe: 存活检查 livenessTip: 当该检查失败时,将重新启动容器,不建议用于大多数用途。 noHealthCheck: "没有给容器配置存活、就绪或启动探测器" readinessProbe: 就绪检查 readinessTip: 当该检查失败时,会将容器从服务端点中移除,建议配置该检查。 startupProbe: 启动检查 startupTip: 容器在尝试其他健康检查之前,将等待此检查成功。 successThreshold: 成功阈值 timeout: 超时时间 kind: none: 无 HTTP: HTTP 请求返回成功的状态 (200-399) HTTPS: HTTPS 请求返回成功的状态 tcp: 成功启动 TCP 连接 exec: 容器内运行的命令以 0 状态退出 image: 容器镜像 imagePullPolicy: 拉取镜像策略 imagePullSecrets: 拉取密钥 init: 初始化容器 name: 容器名称 noResourceLimits: 没有配置资源需求。 noPorts: 当前没有配置端口。 ports: createService: 服务类型 noCreateService: 不创建服务 containerPort: 容器端口 hostIP: 主机 IP hostPort: 公共主机端口 name: 名称 protocol: 协议 listeningPort: 监听端口 removeContainer: 移除容器 security: addCapabilities: 添加功能 addGroupIDs: 添加组 ID allowPrivilegeEscalation: label: 允许权限提升 'false': 否 'true': "是,容器可以获得比其父进程更多的权限。" dropCapabilities: 弃用 Capabilities fsGroup: Filesystem 组 hostIPC: 使用主机 IPC 命名空间 hostPID: 只用主机 PID 命名空间 privileged: label: 特权模式 'false': 否 'true': "是,容器拥有访问主机全部权限" readOnlyRootFilesystem: label: 只读 Root Filesystem 'false': 否 'true': "是,容器有一个只读的文件系统" runAsGroup: 以群组 ID 运行 runAsNonRoot: label: 以非 Root 方式运行 false: 否 true: "是,容器必须以非 root 用户的身份" runAsNonRootOptions: noOption: "否" yesOption: "是:容器必须以非 root 用户的身份运行。" runAsUser: 以用户 ID 运行 shareProcessNamespace: 共享单一进程命名空间 supplementalGroups: 其他组别 ID sysctls: Sysctls sysctlsKey: 名称 standard: 标准容器 titles: container: 容器配置 command: 命令 containers: 容器 env: 环境变量 events: 事件 healthCheck: 健康检查 image: 镜像 networking: 网络 networkSettings: 网络设置 podAnnotations: Pod 注释 podLabels: Pod 标签 podScheduling: Pod 调度 nodeScheduling: 节点调度 ports: 端口映射 resources: 资源限制和预留 securityContext: 安全性上下文 status: 状态 volumeClaimTemplates: PVC 模板 upgrading: 扩缩容/升级策略 cronSchedule: 定时调度 detail: #pods: #title: Pods detailTop: node: 节点 #podIP: Pod IP podRestarts: Pod 重启 workload: 工作负载 #pods: Pods by State #runs: Runs gaugeStates: #active: Active #transitioning: Transitioning warning: 警告 error: 错误 succeeded: 成功 running: 运行中 failed: 失败 hideTabs: '隐藏高级选项' job: activeDeadlineSeconds: label: 活动终止时间 tip: Job 在系统试图终止它之前可能处于活动状态的持续时间。 backoffLimit: label: 重试次数 tip: 标记此 Job 失败之前的重试次数。 completions: label: 完成 Job 历史数 tip: Job 应该运行的成功完成的 Pod 数。 failedJobsHistoryLimit: label: 失败 Job 历史数 tip: 要保留的失败的已完成 Job 的数量。 parallelism: label: 并发数 tip: Job 在给定时间应同时运行的 Pod 的最大数量。 startingDeadlineSeconds: label: 运行 Job 的截止时间 tip: 如果 Job 错过了调度时间,再次尝试运行 Job 的截止时间,单位是秒 successfulJobsHistoryLimit: label: 历史 Successful Job 累计数量 tip: 保留 Successful Job 的数量 suspend: 停止 networking: dnsPolicy: label: DNS 策略 options: clusterFirst: 与配置的集群域后缀不匹配的任何 DNS 查询(例如 “www.kubernetes.io”) 都将转发到从节点继承的上游名称服务器。集群管理员可能配置了额外的存根域和上游 DNS 服务器。 clusterFirstWithHostNet: 对于以 hostNetwork 方式运行的 Pod,应显式设置其 DNS 策略 "ClusterFirstWithHostNet"。 default: 此设置允许 Pod 忽略 Kubernetes 环境中的 DNS 设置。Pod 会使用其 dnsConfig 字段所提供的 DNS 设置。 none: None placeholder: 请选择一个 DNS 策略 hostAliases: add: 添加 keyLabel: IP 地址 keyPlaceholder: 例如:1.1.1.1 label: 主机别名 tip: 使用主机别名向 Pod /etc/hosts 文件添加条目 valueLabel: 主机名 valuePlaceholder: "例如:foo.com, bar.com" hostname: label: 主机名 placeholder: 例如:web nameservers: add: 添加 label: DNS 服务器地址 placeholder: 例如:1.1.1.1 networkMode: label: 网络模式 options: hostNetwork: 主机网络 normal: 集群网络 placeholder: 请选择网络模式 dns: DNS 服务器地址和搜索域 resolver: label: DNS 解析选项 add: 添加 searches: add: 添加 label: 搜索域 placeholder: 例如:mycompany.com subdomain: label: 子域名 placeholder: 例如:web validation: containers: 容器 containerImage: 容器{{name} - “容器镜像 "是必需的。 replicas: 副本 showTabs: '显示高级选项' scheduling: activeDeadlineSeconds: 判定 Pod 是否活跃的截止时间 activeDeadlineSecondsTip: 系统将 Pod 判定为 failed 并杀死其关联的容器前的等待时长 affinity: addNodeSelector: 添加节点选择器 anyNode: 自动匹配节点运行 Pods affinityTitle: 在这些选择器匹配的节点上运行 Pod antiAffinityTitle: 在不与这些选择器匹配的节点上运行 Pod affinityOption: 亲和性 antiAffinityOption: 反亲和性 matchExpressions: addRule: 添加规则 doesNotExist: 不存在 exists: 存在 #greaterThan: ">" in: '=' inNamespaces: "在这些命名空间中的 Pod:" key: 键 #lessThan: < namespaces: 命名空间 notIn: ≠ operator: 运算符 value: 值 weight: 权重 noPodRules: 没有配置 Pod 调度策略 nodeName: 节点名称 priority: 优先级 preferAny: "倾向于任何一种:" preferred: 首选 required: 最好 requireAny: "需要以下任何一种:" schedulingRules: 通过调度规则匹配节点运行 Pods specificNode: 指定节点运行 Pods thisPodNamespace: 此 Pod 的命名空间 topologyKey: label: 拓扑键 placeholder: 例如:failure-domain.beta.kubernetes.io/zone type: 类型 priority: className: 优先级名称 priority: 优先级 terminationGracePeriodSeconds: 终止宽限期 terminationGracePeriodSecondsTip: 终止 Pod 运行前的宽限期 titles: advanced: 高级选项 nodeScheduling: 节点调度 nodeSelector: 具有以下标签的节点 podScheduling: Pod 调度 priority: 优先级 tab: 调度 tolerations: 容忍 limits: 限制和预留 tolerations: addToleration: 添加 effect: 影响 effectOptions: all: 全部 noExecute: 不执行 noSchedule: "不调度" preferNoSchedule: 倾向于不调度 labelKey: 标签键 operator: 运算符 operatorOptions: equal: = exists: 存在 tolerationSeconds: 时间 value: 值 serviceName: 服务名称 storage: subtypes: secret: 密文 configMap: 配置映射 hostPath: Bind-Mount persistentVolumeClaim: PVC createPVC: 创建 PVC #csi: CSI #nfs: NFS #awsElasticBlockStore: Amazon EBS Disk #azureDisk: Azure Disk #azureFile: Azure File #gcePersistentDisk: Google Persistent Disk #driver.longhorn.io: Longhorn #vsphereVolume: VMWare vSphere Volume addClaim: 添加 pvc addMount: 添加 addVolume: 添加卷 certificate: 证书 csi: diskName: 磁盘名称 diskURI: 磁盘 URI cachingMode: label: 缓存模式 options: none: 无 readOnly: 只读 readWrite: 读写 kind: label: 种类 options: dedicated: 专用 managed: 管理 shared: 共用 drivers: #driver.longhorn.io: Longhorn fsType: 文件系统类型 shareName: 共享名 secretName: 密文名称 volumeID: 卷 ID partition: 分区 pdName: 持久磁盘名称 storagePolicyID: 存储策略 ID storagePolicyName: 存储策略名称 volumePath: 存储卷路径 defaultMode: 默认模式 driver: 驱动 hostPath: label: 节点上的路径必须是 options: default: '任何东西:不检查目标路径' directoryOrCreate: 一个目录,如果不存在,则创建一个目录 directory: 现有目录 fileOrCreate: 一个文件,如果它不存在,则创建一个文件 file: 现有文件 socket: 现有 socket charDevice: 现有的字符设备 blockDevice: 现有块设备 mountPoint: 容器挂载路径 nodePath: 路径或节点 optional: label: 选填项 'no': '否' 'yes': '是' path: 路径 readOnly: 只读 server: Server subPath: 卷内子路径 title: '存储' volumeName: 卷名称 volumePath: 卷路径 typeDescriptions: apps.daemonset: DaemonSets 在每个符合条件的节点上正好运行一个 pod。当新节点被添加到集群中时,DaemonSets 会自动部署到它们身上。推荐用于全系统或可垂直扩展的工作负载,每个节点永远不需要超过一个 pod。 apps.deployment: 部署运行分布在符合条件的节点中的可扩展数量的 pod 副本。变更会逐步推出,并可在需要时回滚到之前的版本。推荐用于无状态和水平可扩展的工作负载。 apps.statefulset: StatefulSets 管理有状态的应用程序,并提供关于创建的 pod 的顺序和唯一性的保证。推荐用于具有持久性存储或严格身份、法定人数或升级顺序要求的工作负载。 batch.cronjob: CronJobs 创建 Job,然后按照重复的时间表运行 Pod。该计划以标准的 Unix cron 格式表示,并使用 Kubernetes 控制平面的时区(通常是 UTC)。 batch.job: 作业创建一个或多个 pod,通过运行一个 pod 直到成功退出,可靠地执行一次性任务。失败的 pod 会自动替换,直到达到指定的完成运行次数。作业还可以并行运行多个 pod,或作为批处理工作队列。 upgrading: activeDeadlineSeconds: label: 判定 Pod 是否活跃的截止时间 tip: 系统将 Pod 判定为 failed 并杀死其关联的容器前的等待时长 concurrencyPolicy: label: 并发策略 options: allow: 允许多个 CronJobs 同时运行 forbid: 如果当前运行还没有结束,则跳过下一个运行 replace: 如果当前运行还没有结束,则替换运行 maxSurge: label: 最大 Pod 数量 tip: 在任何给定时间内允许超出所需规模的最大 Pod 数量。 maxUnavailable: label: 最大不可用数量 tip: 在任何给定时间内无法使用的最大 Pod 数量。 minReadySeconds: label: Minimum Ready tip: 在容器没有崩溃的情况下,Pod 被视为可用的最短期限。 podManagementPolicy: label: Pod 管理策略 progressDeadlineSeconds: label: 进程截止时间 tip: 在标志部署失败之前,等待部署取得进展的最短期限。 revisionHistoryLimit: label: 修订历史记录限制 tip: 保留用于回滚的旧 ReplicaSets 的最大数量 strategies: labels: delete: "删除:只有在手动删除旧 pod 时才会创建新 pod" recreate: "重新创建:杀死所有的 pod,然后启动新的 pod。" rollingUpdate: "滚动升级:创建新的 pod,直到达到 max surge,然后再删除旧 pod。停用的 pod 数量不能超过设定的最大不可用数量。" terminationGracePeriodSeconds: label: 終止宽限期 tip: 杀死 Pod 前所需的等待时间 title: 升级中 ############################## # Model Properties ############################## model: account: kind: admin: 管理员 agent: Agent project: 环境 registeredAgent: Registered Agent service: 服务 user: 用户名 "catalog.cattle.io.app": firstDeployed: 首次部署 lastDeployed: 最后部署 #authConfig: #description: #ldap: LDAP #saml: SAML #oauth: OAuth #provider: #system: System #local: Local #multiple: Multiple #activedirectory: ActiveDirectory #azuread: AzureAD #github: GitHub #keycloak: Keycloak #ldap: LDAP #openldap: OpenLDAP #shibboleth: Shibboleth #ping: Ping Identity #adfs: ADFS #okta: Okta #freeipa: FreeIPA #googleoauth: Google cluster: name: 集群名称 ingress: displayKind: 7 层负载均衡 machine: role: #controlPlane: Control Plane #etcd: etcd #worker: Worker openldapconfig: domain: help: 只有此目录下的用户才能正常登录。 label: 用户搜索起点 placeholder: "例如:ou=Users,dc=mycompany,dc=com" server: label: 主机名称或 IP 地址 serviceAccountPassword: label: Service Account 密码 serviceAccountUsername: label: Service Account 用户名 projectMember: role: member: 成员 owner: 所有者 readonly: 只读 restricted: 受限 service: displayKind: generic: 服务 loadBalancer: 4 层负载均衡 typeDescription: #Map of #type: Description to be shown on the top of list view describing the type. # Should fit on one line. # If you link to anything external, it must have # target="_blank" rel="noopener noreferrer nofollow" cis.cattle.io.clusterscanbenchmark: 基准版本是指使用 kube-bench 运行的基准名称,以及该基准的有效配置参数。 cis.cattle.io.clusterscanprofile: 配置文件是 CIS 扫描的配置,也就是要使用的基准版本和该基准中要跳过的任何特定测试。 cis.cattle.io.clusterscan: 创建扫描以根据定义的配置文件在集群上触发 CIS 扫描。扫描完成后会创建一份报告。 cis.cattle.io.clusterscanreport: 报告是对集群进行 CIS 扫描的结果。 resources.cattle.io.backup: 创建备份是为了基于资源集执行一次性备份或安排重复性备份。 resources.cattle.io.restore: 创建还原是为了根据备份文件触发对集群的还原。 resources.cattle.io.resourceset: 资源集定义了要在备份中存储哪些 CRD 和资源。 monitoring.coreos.com.servicemonitor: 服务监视器(service monitor )定义了 Prometheus 将获取的服务组和端点,这是定义指标集合的最常见方法。 monitoring.coreos.com.podmonitor: A pod monitor defines the group of pods that Prometheus will scrape for metrics. The common way is to use service monitors, but pod monitors allow you to handle any situation where a service monitor wouldn't work. monitoring.coreos.com.prometheusrule: Prometheus 规则定义了记录和/或警报规则。记录规则可以预先计算值并保存结果,警报规则允许您定义何时向 AlertManager 发送通知的条件。 monitoring.coreos.com.prometheus: Prometheus 服务器是 deployment 运行的服务,它的刮擦配置和规则是由选定的 ServiceMonitors、PodMonitors 和 PrometheusRules 决定的,它的告警信息将发送给所有选择的具有定制资源配置的 AlertManager。 monitoring.coreos.com.alertmanager: 告警管理器是 deployment 类型运行的服务,其配置将由同一命名空间中的 密文 指定,该 密文 决定了哪些警报应发送给哪个接收者。 catalog.cattle.io.clusterrepo: Chart 仓库是一个 Helm 仓库或 Rancher 的基于 git 的应用商店,它提供了集群中可用的 Chart 列表。 catalog.cattle.io.operation: 操作是指最近应用于集群的 Helm 操作列表。 catalog.cattle.io.app: 已安装的应用程序 Apps 是通过 Rancher catalog 或通过 Helm CLI 安装的 Helm 3 charts。 logging.banzaicloud.io.clusterflow: 集群流定义了要从整个集群收集和过滤哪些日志,以及发送输出哪些日志。集群流需要部署在 logging operator 所在的命名空间中。 logging.banzaicloud.io.flow: 流定义要收集和过滤哪些日志,以及要发送输出哪些日志。该流是一个命名空间资源,这意味着只从部署该流的命名空间收集日志。 logging.banzaicloud.io.clusteroutput: 集群输出定义可以将日志发送到哪些日志提供程序,并且只有部署在 logging operator 所在的命名空间中时才有效。 logging.banzaicloud.io.output: 输出定义可以将日志发送到哪些日志提供程序。输出需要在与使用它的流相同的命名空间中。 logging: 要收集和发送日志,您需要定义流和输出。流定义要收集、筛选哪些日志,以及要发送输出的日志。如果希望收集集群中的所有日志,可以创建一个 ClusterFlow。输出可以在命名空间级别定义,也可以在集群级别定义,并供这两种流类型使用。 typeLabel: management.cattle.io.token: |- {count, plural, one { API Key } other { API Keys } } cis.cattle.io.clusterscan: |- {count, plural, one { 扫描 } other { 扫描 } } cis.cattle.io.clusterscanprofile: |- {count, plural, one { 配置文件 } other { 配置文件 } } cis.cattle.io.clusterscanbenchmark: |- {count, plural, one { Benchmark 版本 } other { Benchmark 版本 } } catalog.cattle.io.operation: |- {count, plural, one { 最近的操作 } other { 最近的操作 } } catalog.cattle.io.app: |- {count, plural, one { 已安装的 App } other { 已安装的 Apps } } catalog.cattle.io.clusterrepo: |- {count, plural, one { Chart 仓库 } other { Chart 仓库 } } catalog.cattle.io.repo: |- {count, plural, one { Namespaced Repo } other { Namespaced Repos } } chartInstallAction: |- {count, plural, one { App } other { Apps } } chartUpgradeAction: |- {count, plural, one { App } other { Apps } } endpoints: |- {count, plural, one { Endpoint } other { Endpoints } } fleet.cattle.io.cluster: |- {count, plural, =1 { 集群 } other { 集群 } } fleet.cattle.io.clustergroup: |- {count, plural, one { 集群组 } other { 集群组 } } fleet.cattle.io.gitrepo: |- {count, plural, one { Git 仓库 } other {Git 仓库 } } management.cattle.io.authconfig: |- {count, plural, one { Auth Provider } other { Auth Providers } } management.cattle.io.feature: |- {count, plural, one { Feature Flag } other { Feature Flags } } management.cattle.io.setting: |- {count, plural, one { 高级设置 } other { 高级设置 } } management.cattle.io.fleetworkspace: |- {count, plural, one { 工作空间 } other { 工作空间 } } #pruh-mee-thee-eyes https://www.prometheus.io/docs/introduction/faq/#what-is-the-plural-of-prometheus monitoring.coreos.com.prometheus: |- {count, plural, one { Prometheus } other { Prometheis } } monitoring.coreos.com.servicemonitor: |- {count, plural, one { 服务监控 } other { 服务监控 } } monitoring.coreos.com.alertmanager: |- {count, plural, one { 告警管理 } other { 告警管理 } } monitoring.coreos.com.podmonitor: |- {count, plural, one { Pod 监控 } other { Pod 监控 } } monitoring.coreos.com.prometheusrule: |- {count, plural, one { Prometheus 规则 } other { Prometheus 规则 } } monitoring.coreos.com.thanosruler: |- {count, plural, one { Thanos 规则 } other { Thanos 规则 } } monitoring.coreos.com.receiver: |- {count, plural, one { 接收者 } other { 接收者 } } monitoring.coreos.com.route: |- {count, plural, one { 通知 } other { 通知 } } 'management.cattle.io.cluster': |- {count, plural, one { 所有集群 } other { 所有集群 } } 'cluster.x-k8s.io.cluster': |- {count, plural, one { CAPI集群 } other { CAPI集群 } } 'rancher.cattle.io.cluster': |- {count, plural, one { 集群 } other { 集群 } } 'management.cattle.io.user': |- {count, plural, one { 用户 } other { 用户 } } group.principal: |- {count, plural, one { 组 } other { 组 } } token: |- {count, plural, one { API密钥 } other { API密钥 } } action: clone: 克隆 disable: 禁用 download: 下载 YAML edit: 编辑配置 editYaml: 编辑 YAML enable: 启用 openLogs: 查看日志 refresh: 刷新 remove: 删除 view: 查看配置 viewInApi: API 查看 activate: 激活 deactivate: 停用 show: 显示 hide: 隐藏 copy: 复制 unassign: 取消分配 unit: sec: secs min: mins hour: |- {count, plural, one { 小时 } other { 小时 } } day: |- {count, plural, one { 天 } other { 天 } } workloadPorts: addPort: 添加 remove: 移除 addHost: 添加主机 podAffinity: addLabel: 添加 Pod 选择器 keyValue: keyPlaceholder: '例如: foo' valuePlaceholder: '例如: bar' ############################### ### 高级设置 ############################### advancedSettings: label: 高级设置 subtext: 一般用户不需要改变这些。请谨慎操作,一旦输入了不正确的值,会破坏你的{appName}安装。从默认设置中定制的设置被标记为 "已修改"。 show: 显示 hide: 隐藏 none: 无 edit: label: 编辑设置 changeSetting: "修改设置" trueOption: "True" falseOption: "False" value: 值 useDefault: 复制默认值 invalidJSON: 无效的JSON - 请在保存前检查并修改您的输入。 descriptions: 'cacerts': "验证服务器的证书所需的CA证书。" 'cluster-defaults': '在创建新集群时覆盖RKE默认值。' 'engine-install-url': '默认的Docker引擎安装URL(用于大多数节点驱动)。' 'engine-iso-url': '默认的操作系统安装URL(用于vSphere驱动)。' 'engine-newest-version': '在本次发布时,最新的Docker支持版本。 不满足支持的Docker范围但比这更新的Docker版本将被标记为未测试。' 'engine-supported-range': '支持Docker引擎版本的Semver范围。 不符合这个范围的版本将在用户界面中被标记为不支持。' 'ingress-ip-domain': '用于自动生成Ingress主机名的通配符DNS域。..将被添加到该域。' 'server-url': '默认的{appName}安装网址。必须是HTTPS。你的集群中的所有节点都必须能够到达这里。' 'system-default-registry': '用于所有系统Docker镜像的私有仓库。' 'ui-index': 'UI的HTML索引。' 'ui-pl': 'Private-Label company name.' 'ui-issues': '使用一个URL地址来发送新的 "提交问题 "报告,而不是将用户发送到GitHub问题页面。' 'telemetry-opt': '遥测报告opt-in。' 'auth-user-info-max-age-seconds': '在进行认证提供者组成员同步之前,用户认证令牌的最大存活时间。' 'auth-user-info-resync-cron': '重新同步认证提供者组成员资格的默认cron时间表。' 'cluster-template-enforcement': '非管理员将被限制只能通过预先批准的RKE模板启动集群。' 'auth-user-session-ttl-minutes': '用户认证会话的自定义TTL(以分钟为单位)。' 'auth-token-max-ttl-minutes': '自定义一个授权令牌的最大TTL(以分钟为单位)。' 'rke-metadata-config': '配置RKE元数据刷新参数。' 'ui-banners': '分类横幅是用来在页眉、页脚或两者中显示一个自定义的固定横幅。' 'ui-default-landing': '用户在登录后登陆的默认页面。' editHelp: 'ui-banners': 这个设置需要一个JSON对象,包含3个根参数;banner, showHeader, showFooterbanner是一个包含;textColor, background, 和text的对象,其中textColorbackground是任何有效的CSS颜色值。 #enum: #'ui-default-landing': # ember: Cluster Manager #vue: Cluster Explorer #'telemetry-opt': #prompt: Prompt #in: Opt-in to Telemetry #out: Opt-out of Telemetry featureFlags: label: 功能标志 warning: 功能标志允许Rancher将某些功能关在标志后面。你应该谨慎地启用这些功能,它们应该被视为测试版功能,可能会给你的系统带来问题。此外,有些功能(非动态)需要重新启动Rancher服务器才能启用。改变非动态功能将重新启动Rancher pods,这将导致短暂的停电。 promptActivate: 请确认您要激活功能标志"{flag}"。 promptDeactivate: 请确认你想停用功能标志"{flag}"。 restartRequired: "注意:更新该功能标志需要重新启动" ############################## ### Support Page ############################## support: community: title: SUSE Rancher 提供世界一流的支持 register: 已经购买支持?请添加您的SUSE订阅ID。 addSubscription: 添加订阅ID linksTitle: 社区支持 learnMore: 了解更多关于SUSE Rancher支持的信息 suse: title: "好消息--你得到了保障" promos: one: title: 全天候7x24小时支持 text: 我们提供严格定义的服务水平协议,并提供全天候的支持选项。 two: title: 解决问题 text: 满怀信心地运行 SUSE Rancher 产品,因为我们知道构建这些产品的开发人员可以快速解决问题。 three: title: 问题排查 text: 我们专注于发现任何问题的根源,无论它是否与Rancher产品、Kubernetes、Docker或你的底层基础设施有关。 four: title: 自由创新 text: 利用我们与众多的Kubernetes厂商、操作系统和开源软件的认证兼容性。 ================================================ FILE: pkg/rancher-desktop/backend/__tests__/backendHelper.spec.ts ================================================ /** @jest-environment node */ import _ from 'lodash'; import type { VMExecutor } from '@pkg/backend/backend'; import type BackendHelperType from '@pkg/backend/backendHelper'; import mockModules from '@pkg/utils/testUtils/mockModules'; const modules = mockModules({ electron: undefined, }); describe('BackendHelper', () => { let BackendHelper: typeof BackendHelperType; beforeAll(async() => { BackendHelper = (await import('@pkg/backend/backendHelper')).default; }); describe('configureMobyStorage', () => { const snapshotterDir = '/var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/'; const classicDir = '/var/lib/docker/image/overlay2/imagedb/content/sha256/'; // no-spell-check const DOCKER_DAEMON_JSON = '/etc/docker/daemon.json'; interface Options { hasSnapshotter: boolean; hasClassic: boolean; useWASM: boolean; storageDriver: 'classic' | 'snapshotter' | 'auto'; } class mockExecutor implements Partial { readonly options: Omit & { existingConfig?: string; }; readonly backend = 'mock'; result: any; constructor(options: typeof this.options) { this.options = options; } execCommand(...command: string[]): Promise; execCommand(options: unknown, ...command: string[]): Promise; execCommand(options: unknown, ...command: string[]): Promise; execCommand(options?: unknown, ...command: string[]): Promise | Promise { if (typeof options === 'string') { command.unshift(options); } switch (command[0]) { case '/usr/bin/find': expect(options).toHaveProperty('capture', true); if (command.includes(snapshotterDir)) { return Promise.resolve(this.options.hasSnapshotter ? 'some text\n' : '\n'); } if (command.includes(classicDir)) { return Promise.resolve(this.options.hasClassic ? 'not empty\n' : '\n'); } break; case 'mkdir': return Promise.resolve(); } throw new Error(`Unexpected command: ${ JSON.stringify(command) }`); } readFile(filePath: string): Promise { if (filePath === DOCKER_DAEMON_JSON) { if (this.options.existingConfig) { return Promise.resolve(this.options.existingConfig); } return Promise.reject(new Error('file does not exist')); } throw new Error(`Unexpected readFile: ${ filePath }`); } writeFile(filePath: string, fileContents: string): Promise { expect(filePath).toEqual(DOCKER_DAEMON_JSON); const config = JSON.parse(fileContents); this.result = config; expect(config).toHaveProperty('features.containerd-snapshotter'); return Promise.resolve(); } } async function runTest(options: Options): Promise { const vmx = new mockExecutor(options); await BackendHelper.configureMobyStorage( vmx as unknown as VMExecutor, options.storageDriver, options.useWASM); expect(vmx.result).toHaveProperty('features.containerd-snapshotter'); return vmx.result.features['containerd-snapshotter'] ?? false; } function generateCases(alwaysUseWASM: boolean) { const cases: Omit[] = []; const bools = [true, false]; for (const hasSnapshotter of bools) { for (const hasClassic of bools) { if (!alwaysUseWASM) { for (const useWASM of bools) { cases.push({ hasSnapshotter, hasClassic, useWASM }); } } else { cases.push({ hasSnapshotter, hasClassic, useWASM: true }); } } } return cases; } it.concurrent.each(generateCases(false))( 'should use classic storage driver when specified (snapshotter:$hasSnapshotter classic:$hasClassic wasm:$useWASM)', async(options) => { await expect(runTest({ ...options, storageDriver: 'classic' })).resolves.toBeFalsy(); }); it.concurrent.each(generateCases(false))( 'should use snapshotter storage driver when specified (snapshotter:$hasSnapshotter classic:$hasClassic wasm:$useWASM)', async(options) => { await expect(runTest({ ...options, storageDriver: 'snapshotter' })).resolves.toBeTruthy(); }); it.concurrent.each(generateCases(true))( 'should choose storage driver based on WASM configuration when set to auto (snapshotter:$hasSnapshotter classic:$hasClassic)', async(options) => { await expect(runTest({ ...options, useWASM: true, storageDriver: 'auto' })).resolves.toBeTruthy(); }); it.concurrent.each` hasSnapshotter | hasClassic | expected ${ true } | ${ true } | ${ true } ${ true } | ${ false } | ${ true } ${ false } | ${ true } | ${ false } ${ false } | ${ false } | ${ true } `('should choose storage driver based on existing usage when set to auto and WASM disabled (snapshotter:$hasSnapshotter classic:$hasClassic)', async(options) => { await expect(runTest({ ...options, useWASM: false, storageDriver: 'auto' })).resolves.toBe(options.expected); }); it('should preserve existing docker daemon settings', async() => { const existingConfig = { hello: 'world', }; const vmx = new mockExecutor({ hasSnapshotter: false, hasClassic: true, existingConfig: JSON.stringify(existingConfig), }); await BackendHelper.configureMobyStorage( vmx as unknown as VMExecutor, 'auto', false); expect(vmx.result).toHaveProperty('features.containerd-snapshotter'); expect(vmx.result).toHaveProperty('hello', 'world'); }); }); }); ================================================ FILE: pkg/rancher-desktop/backend/__tests__/k3sHelper.spec.ts ================================================ /** @jest-environment node */ import fs from 'fs'; import os from 'os'; import path from 'path'; import util from 'util'; import { jest } from '@jest/globals'; import semver from 'semver'; import { SemanticVersionEntry } from '@pkg/utils/kubeVersions'; import paths from '@pkg/utils/paths'; import mockModules from '@pkg/utils/testUtils/mockModules'; import type { ReleaseAPIEntry } from '../k3sHelper'; const cachePath = path.join(paths.cache, 'k3s-versions.json'); const modules = mockModules({ '@pkg/utils/logging': undefined, electron: undefined, }); const { default: K3sHelper, buildVersion, ChannelMapping, NoCachedK3sVersionsError } = await import('../k3sHelper'); let cacheData: Buffer | null; beforeAll(() => { try { cacheData = fs.readFileSync(cachePath); } catch (err) { cacheData = null; } }); afterAll(() => { if (cacheData) { fs.writeFileSync(cachePath, cacheData); } else { fs.rmSync(cachePath); } }); beforeEach(() => { modules.electron.net.fetch.mockReset(); }); describe(buildVersion, () => { test('parses the build number', () => { expect(buildVersion(new semver.SemVer('v1.99.3+k3s4'))).toEqual(4); }); test('handles non-conforming versions', () => { expect(buildVersion(new semver.SemVer('v1.99.3'))).toEqual(-1); }); }); describe(K3sHelper, () => { describe('processVersion', () => { let subject: InstanceType; const process = (name: string, existing: string[] = [], hasAssets = false) => { const assets: ReleaseAPIEntry['assets'] = []; if (hasAssets) { for (const name of Object.values(subject['filenames'])) { if (typeof name === 'string') { assets.push({ name, browser_download_url: name }); } else { assets.push({ name: name[0], browser_download_url: name[0] }); } } } for (const version of existing) { const parsed = new semver.SemVer(version); subject['versions'][parsed.version] = new SemanticVersionEntry(parsed); } return subject['processVersion']({ tag_name: name, assets }); }; beforeEach(() => { subject = new K3sHelper('x86_64'); // Note that we _do not_ initialize this, i.e. we don't trigger an // initial fetch of the releases. Instead, we pretend that is done. subject['pendingInitialize'] = Promise.resolve(); }); it('should skip invalid versions', async() => { expect(process('xxx')).toEqual(true); expect(await subject.availableVersions).toHaveLength(0); }); it('should skip prereleases', async() => { expect(process('1.99.3-beta1')).toEqual(true); expect(await subject.availableVersions).toHaveLength(0); }); it('should skip valid but erroneous versions', async() => { expect(process('1.99.3+rk3s1')).toEqual(true); expect(await subject.availableVersions).toHaveLength(0); }); it('should ignore old versions', async() => { expect(process('1.2.0')).toEqual(true); expect(await subject.availableVersions).toHaveLength(0); }); it('should ignore obsolete builds', async() => { expect(process('1.99.3+k3s4', ['1.99.3+k3s5'])).toEqual(true); expect(await subject.availableVersions).toHaveLength(1); }); it('should ignore existing builds', async() => { expect(process('1.99.3+k3s4', ['1.99.3+k3s4'])).toEqual(false); expect(await subject.availableVersions).toHaveLength(1); }); it('should ignore versions with missing assets', async() => { expect(process('1.99.3+k3s4')).toEqual(true); expect(await subject.availableVersions).toHaveLength(0); }); it('should add versions', async() => { expect(process('1.99.3+k3s4', [], true)).toEqual(true); expect(await subject.availableVersions).toHaveLength(1); }); }); test('cache read/write', async() => { const subject = new K3sHelper('x86_64'); const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-test-cache-')); const versions: Record = { '1.99.3': new SemanticVersionEntry(semver.parse('1.99.3+k3s1')!, ['stable']), '2.3.4': new SemanticVersionEntry(semver.parse('2.3.4+k3s3')!), }; const versionStrings = Object.values(versions) .map(v => v.version) .sort((a, b) => a.compare(b)) .map(v => v.raw); try { // We need to cast to any in order to override readonly. (subject as any).cachePath = path.join(workDir, 'cache.json'); subject['versions'] = versions; await subject['writeCache'](); const actual = JSON.parse(await fs.promises.readFile(subject['cachePath'], 'utf8')); const { versions: actualStrings, channels }: { versions: string[], channels: Record } = actual; expect(actual).toHaveProperty('cacheVersion'); expect(semver.sort(actualStrings)).toEqual(versionStrings); expect(channels).toEqual({ stable: '1.99.3' }); // Check that we can load the values back properly subject['versions'] = {}; await subject['readCache'](); expect(subject['versions']).toEqual(versions); } finally { await util.promisify(fs.rm)(workDir, { recursive: true, force: true }); } }); test('updateCache', async() => { const subject = new K3sHelper('x86_64'); const validAssets = Object.values(subject['filenames']).map((name) => { if (typeof name === 'string') { return { name, browser_download_url: name }; } else { return { name: name[0], browser_download_url: name[0] }; } }); // Override cache reading to return a fake existing cache. // The first read returns nothing to trigger a synchronous update; // the rest of the reads return mocked values. jest.spyOn(subject as any, 'readCache') .mockResolvedValueOnce(undefined) .mockImplementation(function(this: InstanceType) { const result = new ChannelMapping(); for (const [version, tags] of Object.entries({ 'v1.99.1+k3s1': ['stale-tag'], 'v1.99.3+k3s1': ['stable'], })) { const parsedVersion = new semver.SemVer(version); this.versions[parsedVersion.version] = new SemanticVersionEntry(parsedVersion, tags); for (const tag of tags) { result[tag] = parsedVersion; } } return Promise.resolve(result); }); subject['writeCache'] = jest.fn(() => Promise.resolve()); // On rate limiting, continue immediately. subject['delayForWaitLimiting'] = jest.fn(() => Promise.resolve()); // Fake out the results modules.electron.net.fetch .mockImplementationOnce((url) => { expect(url).toEqual(subject['channelApiUrl']); return Promise.resolve(new Response( JSON.stringify({ resourceType: 'channels', data: [{ type: 'channel', name: 'stable', latest: 'v1.99.3+k3s3', }], }), )); }) .mockImplementationOnce((url) => { expect(url).toEqual(subject['releaseApiUrl']); return Promise.resolve(new Response( JSON.stringify([ { tag_name: 'v1.99.3+k3s2', assets: validAssets }, { tag_name: 'v1.99.3+k3s3', assets: validAssets }, // The next one is skipped because there's a newer build { tag_name: 'v1.99.3+k3s1', assets: validAssets }, { tag_name: 'v1.99.4+k3s1', assets: [] }, { tag_name: 'v1.99.1+k3s2', assets: validAssets }, ]), { headers: { link: '; rel="next"' } }, )); }) .mockImplementationOnce((url) => { expect(url).toEqual('url'); return Promise.resolve(new Response( undefined, { status: 403, headers: { 'X-RateLimit-Remaining': '0' } }, )); }) .mockImplementationOnce((url) => { expect(url).toEqual('url'); return Promise.resolve(new Response( JSON.stringify([ { tag_name: 'Invalid tag name', assets: validAssets }, { tag_name: 'v1.99.0+k3s5', assets: validAssets }, ]), { headers: { link: '; rel="first"' } }, )); }) .mockImplementationOnce((url) => { throw new Error(`Unexpected fetch call to ${ url }`); }); // Ensure the Latch is set up in K3sHelper subject.networkReady(); await subject.initialize(); expect(modules.electron.net.fetch).toHaveBeenCalledTimes(4); expect(subject['delayForWaitLimiting']).toHaveBeenCalledTimes(1); expect(await subject.availableVersions).toEqual([ new SemanticVersionEntry(new semver.SemVer('v1.99.3+k3s3'), ['stable']), new SemanticVersionEntry(new semver.SemVer('v1.99.1+k3s2')), new SemanticVersionEntry(new semver.SemVer('v1.99.0+k3s5')), ]); }); test('updateCache with new versions', async() => { const subject = new K3sHelper('x86_64'); const validAssets = Object.values(subject['filenames']).map((name) => { if (typeof name === 'string') { return { name, browser_download_url: name }; } else { return { name: name[0], browser_download_url: name[0] }; } }); // Override cache reading to return a fake existing cache. // The first read returns nothing to trigger a synchronous update; // the rest of the reads return mocked values. jest.spyOn(subject, 'readCache' as any) .mockResolvedValueOnce(undefined) .mockImplementation(function(this: InstanceType) { const result = new ChannelMapping(); for (const [version, tags] of Object.entries({ 'v1.96.0+k3s2': [], 'v1.96.1+k3s1': [], 'v1.96.2+k3s1': [], 'v1.96.3+k3s1': ['v1.96', 'stable'], 'v1.97.1+k3s1': [], 'v1.97.2+k3s1': [], 'v1.97.3+k3s1': [], 'v1.97.4+k3s1': [], 'v1.97.5+k3s1': ['v1.97', 'latest'], })) { const parsedVersion = new semver.SemVer(version); this.versions[parsedVersion.version] = new SemanticVersionEntry(parsedVersion, tags); for (const tag of tags) { result[tag] = parsedVersion; } } subject['versionFromChannel'] = { stable: '1.96.3', latest: '1.97.5', 'v1.96': '1.96.3', 'v1.97': '1.97.5', }; return Promise.resolve(result); }); subject['writeCache'] = jest.fn(() => Promise.resolve()); // Fake out the results modules.electron.net.fetch .mockImplementationOnce((url) => { expect(url).toEqual(subject['channelApiUrl']); return Promise.resolve(new Response( JSON.stringify({ resourceType: 'channels', data: [ { type: 'channel', name: 'v1.96', latest: '1.96.9+k3s1', }, { type: 'channel', name: 'v1.97', latest: '1.97.7+k3s1', }, { type: 'channel', name: 'stable', latest: '1.97.7+k3s1', }, { type: 'channel', name: 'latest', latest: '1.98.3+k3s1', }, { type: 'channel', name: 'v1.98', latest: '1.98.3+k3s1', }, ], }), )); }) .mockImplementationOnce((url) => { expect(url).toEqual(subject['releaseApiUrl']); return Promise.resolve(new Response( JSON.stringify([ { tag_name: 'v1.98.3+k3s2', assets: validAssets }, { tag_name: 'v1.98.2+k3s2', assets: validAssets }, { tag_name: 'v1.98.1+k3s2', assets: validAssets }, { tag_name: 'v1.97.7+k3s2', assets: validAssets }, { tag_name: 'v1.97.6+k3s1', assets: validAssets }, ]), { headers: { link: '; rel="first"' } }, )); }) .mockImplementationOnce((url) => { throw new Error(`Unexpected fetch call to ${ url }`); }); // Ensure the Latch is set up in K3sHelper subject.networkReady(); await subject.initialize(); expect(modules.electron.net.fetch).toHaveBeenCalledTimes(2); const availableVersions = await subject.availableVersions; expect(availableVersions).toEqual([ new SemanticVersionEntry(new semver.SemVer('v1.98.3+k3s2'), ['latest', 'v1.98']), new SemanticVersionEntry(new semver.SemVer('v1.98.2+k3s2')), new SemanticVersionEntry(new semver.SemVer('v1.98.1+k3s2')), new SemanticVersionEntry(new semver.SemVer('v1.97.7+k3s2'), ['stable', 'v1.97']), new SemanticVersionEntry(new semver.SemVer('v1.97.6+k3s1')), new SemanticVersionEntry(new semver.SemVer('v1.97.5+k3s1')), new SemanticVersionEntry(new semver.SemVer('v1.97.4+k3s1')), new SemanticVersionEntry(new semver.SemVer('v1.97.3+k3s1')), new SemanticVersionEntry(new semver.SemVer('v1.97.2+k3s1')), new SemanticVersionEntry(new semver.SemVer('v1.97.1+k3s1')), new SemanticVersionEntry(new semver.SemVer('v1.96.3+k3s1'), ['v1.96']), new SemanticVersionEntry(new semver.SemVer('v1.96.2+k3s1')), new SemanticVersionEntry(new semver.SemVer('v1.96.1+k3s1')), new SemanticVersionEntry(new semver.SemVer('v1.96.0+k3s2')), ]); // Verify versionFromChannel is updated after channel assignment expect(subject['versionFromChannel']).toEqual({ 'v1.96': '1.96.3', 'v1.97': '1.97.7', 'v1.98': '1.98.3', stable: '1.97.7', latest: '1.98.3', }); }); describe('initialize', () => { it('should finish initialize without network if cache is available', async() => { const writer = new K3sHelper('x86_64'); writer['versions'] = { 'v1.99.0': new SemanticVersionEntry(new semver.SemVer('v1.99.0')) }; await writer['writeCache'](); // We want to check that initialize() returns before updateCache() does. const subject = new K3sHelper('x86_64'); const pendingInit = new Promise((resolve) => { // We need a cast on updateCache here since it's a protected method. jest.spyOn(subject, 'updateCache' as any).mockImplementation(async() => { // This will be called, but will not block initialization. await pendingInit; return []; }); subject.initialize().then(resolve); }); expect(await subject.availableVersions).toContainEqual({ version: semver.parse('v1.99.0'), channels: undefined, }); await pendingInit; }); it('should not reset versionFromChannel when called multiple times', async() => { const subject = new K3sHelper('x86_64'); // Mock readCache to populate versionFromChannel jest.spyOn(subject as any, 'readCache').mockImplementation(function(this: InstanceType) { this.versions['1.99.3'] = new SemanticVersionEntry(semver.parse('1.99.3+k3s1')!, ['stable']); this.versionFromChannel['stable'] = '1.99.3'; return Promise.resolve(); }); // Mock updateCache to not actually run jest.spyOn(subject as any, 'updateCache').mockResolvedValue(undefined); // First initialization await subject.initialize(); expect(subject['versionFromChannel']).toEqual({ stable: '1.99.3' }); // Second initialization should not reset versionFromChannel await subject.initialize(); expect(subject['versionFromChannel']).toEqual({ stable: '1.99.3' }); }); }); describe('selectClosestSemVer', () => { const subject = K3sHelper; const table = [ ['finds the oldest newer major version', 'v3.1.2+k3s3', ['v1.2.9+k3s1', 'v1.2.9+k3s4', 'v4.2.8+k3s1', 'v4.3.0+k3s1'], 'v4.2.8+k3s1'], ['finds the oldest newer minor version', 'v1.12.2+k3s3', ['v1.2.9+k3s1', 'v1.7.0+k3s1', 'v1.29.9+k3s4', 'v2.12.8+k3s1'], 'v1.29.9+k3s4'], ['finds the oldest newer patch version at the start of the list', 'v1.12.2+k3s3', ['v1.12.4+k3s1', 'v1.12.4+k3s4', 'v1.12.8+k3s1', 'v1.12.9+k3s4'], 'v1.12.4+k3s4'], ['finds the oldest newer patch version inside the list', 'v1.12.10+k3s99', ['v1.12.4+k3s1', 'v1.12.8+k3s1', 'v1.12.9+k3s1', 'v1.12.20+k3s4'], 'v1.12.20+k3s4'], ['settles on the newest older version', 'v1.12.11+k3s5', ['v1.12.4+k3s1', 'v1.12.4+k3s4', 'v1.12.8+k3s1', 'v1.12.9+k3s4'], 'v1.12.9+k3s4'], ['favor a lower build number for same version over a newer version', 'v1.2.9+k3s2', ['v1.2.8+k3s1', 'v1.2.9+k3s1', 'v1.2.10+k3s1', 'v1.2.10+k3s2'], 'v1.2.9+k3s1'], ['finds the highest build version over single digits', 'v1.2.9+k3s2', ['v1.2.8+k3s1', 'v1.2.9+k3s1', 'v1.2.9+k3s4', 'v1.3.0+k3s1'], 'v1.2.9+k3s4'], ['finds the highest build version over double digits', 'v1.2.9+k3s11', ['v1.2.9+k3s9', 'v1.2.9+k3s15', 'v1.2.9+k3s16', 'v1.3.0+k3s1'], 'v1.2.9+k3s16'], ['can handle non-conforming inputs', 'v1.2.3+k3s4', ['v1.2.2+k3s1', 'oswald', 'rabbit', 'v1.2.4+k3s4'], 'v1.2.4+k3s4'], ] as const; test.each(table)('%s', (title: string, desiredVersion: string, cachedFilenames: readonly [string, string, string, string], expected: string) => { const desiredSemver = new semver.SemVer(desiredVersion); const selectedSemVer = subject['selectClosestSemVer'](desiredSemver, cachedFilenames as unknown as string[]); expect(selectedSemVer).toHaveProperty('raw', expected); }); test('can handle zero choices', () => { const desiredSemver = new semver.SemVer('v1.99.3+k3s4'); expect(() => subject['selectClosestSemVer'](desiredSemver, [])).toThrow(NoCachedK3sVersionsError); }); }); }); ================================================ FILE: pkg/rancher-desktop/backend/backend.ts ================================================ import fs from 'fs'; import stream from 'stream'; import { Settings } from '@pkg/config/settings'; import * as childProcess from '@pkg/utils/childProcess'; import EventEmitter from '@pkg/utils/eventEmitter'; import { RecursiveKeys, RecursivePartial, RecursiveReadonly } from '@pkg/utils/typeUtils'; import type { ContainerEngineClient } from './containerClient'; import type { KubernetesBackend } from './k8s'; export enum State { STOPPED = 'STOPPED', // The engine is not running. STARTING = 'STARTING', // The engine is attempting to start. STARTED = 'STARTED', // The engine is started; the dashboard is not yet ready. STOPPING = 'STOPPING', // The engine is attempting to stop. ERROR = 'ERROR', // There is an error and we cannot recover automatically. DISABLED = 'DISABLED', // The container backend is ready but the Kubernetes engine is disabled. } export class BackendError extends Error { constructor(name: string, message: string, fatal = false) { super(message); this.name = name; this.fatal = fatal; } readonly fatal: boolean; } export interface BackendProgress { /** The current progress; valid values are 0 to max. */ current: number, /** Maximum progress possible; if less than zero, the progress is indeterminate. */ max: number, /** Details on the current action. */ description?: string, /** When we entered this progress state. */ transitionTime?: Date, } export type Architecture = 'x86_64' | 'aarch64'; export interface FailureDetails { /** The last lima/wsl command run: */ lastCommand?: string, lastCommandComment: string, lastLogLines: string[], } /** * KubernetesBackendEvents describes the events that may be emitted by a * Kubernetes backend (as an EventEmitter). Each property name is the name of * an event, and the property type is the type of the callback function expected * for the given event. */ export interface BackendEvents { /** * Emitted when there has been a change in the progress in the current action. * The progress can be read off the `progress` member on the backend. */ 'progress'(): void; /** * Emitted when the state of the backend has changed. */ 'state-changed'(state: State): void; /** * Show a notification to the user. */ 'show-notification'(options: Electron.NotificationConstructorOptions): void; } /** * Settings that KubernetesBackend can access. */ export type BackendSettings = RecursiveReadonly; /** * Reasons that the backend might need to restart, as returned from * `requiresRestartReasons()`. * @returns A mapping of the preference causing the restart to the changed * values. */ export type RestartReasons = Partial, { /** * The currently active value. */ current: any; /** * The desired value (which must be different from the current value to * require a restart). */ desired: any; /** * The severity of the restart; this may be set to `reset` for some values * indicating that there will be data loss. */ severity: 'restart' | 'reset'; }>>; /** * VMBackend describes a controller for managing a virtual machine upon which * Rancher Desktop runs. */ export interface VMBackend extends EventEmitter { /** The name of the VM backend */ readonly backend: 'wsl' | 'lima' | 'mock'; readonly state: State; /** The number of CPUs in the running VM, or 0 if the VM is not running. */ readonly cpus: Promise; /** The amount of memory in the VM, in MiB, or 0 if the VM is not running. */ readonly memory: Promise; /** Progress for the current action. */ readonly progress: Readonly; /** * Whether debug mode is enabled. If this is set, the implementation should * emit extra debug logging if possible. */ debug: boolean; /** * Check if the current backend is valid. * @returns Null if the backend is valid; otherwise, an error describing why * the backend is invalid that can be shown to the user. */ getBackendInvalidReason(): Promise; /** * Start the Kubernetes cluster. If it is already started, it will be * restarted. */ start(config: BackendSettings): Promise; /** Stop the Kubernetes cluster. If applicable, shut down the VM. */ stop(): Promise; /** Delete the Kubernetes cluster, returning the exit code. */ del(): Promise; /** Reset the Kubernetes cluster, removing all workloads. */ reset(config: BackendSettings): Promise; /** * Apply the settings update that does not require a backend restart. */ handleSettingsUpdate(config: BackendSettings): Promise; /** * Check if applying the given settings would require the backend to restart. */ requiresRestartReasons(config: RecursivePartial): Promise; /** * Get the external IP address where the services would be listening on, if * available. For VM-based systems, this would be the address of the VM's * network interface. This address may be undefined if the backend is * currently not in a state that supports services; for example, if the VM is * off. */ readonly ipAddress: Promise; /** * If called after a backend operation fails, this returns a block of data that attempts * to give more information about what command was being run when the error happened. * * @param [exception] The associated exception. */ getFailureDetails(exception: any): Promise; /** * If true, the backend cannot invoke any dialog boxes and needs to find an alternative. */ noModalDialogs: boolean; readonly executor: VMExecutor; readonly kubeBackend: KubernetesBackend; readonly containerEngineClient: ContainerEngineClient; } /** * execOptions is options for VMExecutor. */ export type execOptions = childProcess.CommonOptions & { /** Expect the command to fail; do not log on error. Exceptions are still thrown. */ expectFailure?: boolean; /** A custom log stream to write to; must have a file descriptor. */ logStream?: stream.Writable; /** * If set, ensure that the command is run as the privileged user. * @note The command is always run as root on WSL. */ root?: boolean; }; /** * VMExecutor describes how to run commands in the virtual machine. */ export interface VMExecutor { /** * The backend in use. */ readonly backend: VMBackend['backend']; /** * execCommand runs the given command in the virtual machine. * @param execOptions Execution options. If capture is set, standard output is * returned. * @param command The command to execute. */ execCommand(...command: string[]): Promise; execCommand(options: execOptions, ...command: string[]): Promise; execCommand(options: execOptions & { capture: true }, ...command: string[]): Promise; /** * spawn the given command in the virtual machine, returning the child * process itself. * @note On Windows, this will be within the network / pid namespace. * @param options Execution options. * @param command The command to execute. */ spawn(...command: string[]): childProcess.ChildProcess; spawn(options: execOptions, ...command: string[]): childProcess.ChildProcess; /** * Read the contents of the given file. If the file is a symlink, the target * will be read instead. * @param filePath The path inside the VM to read. * @param [options.encoding='utf-8'] The encoding of the file. * @returns The contents of the file. */ readFile(filePath: string): Promise; readFile(filePath: string, options: Partial<{ encoding: BufferEncoding }>): Promise; /** * Write the given contents to a given file name in the VM. * The file will be owned by root. * @param filePath The destination file path, in the VM. * @param fileContents The contents of the file. * @param permissions The file permissions. Defaults to 0o644. */ writeFile(filePath: string, fileContents: string): Promise; writeFile(filePath: string, fileContents: string, permissions: fs.Mode): Promise; /** * Copy the given file from the host into the VM. * @param hostPath The source path, on the host. * @param vmPath The destination path, inside the VM. * @note The behaviour of copying a directory is undefined. */ copyFileIn(hostPath: string, vmPath: string): Promise; /** * Copy the given file from the VM into the host. * @param vmPath The source path, inside the VM. * @param hostPath The destination path, on the host. * @note The behaviour of copying a directory is undefined. */ copyFileOut(vmPath: string, hostPath: string): Promise; } ================================================ FILE: pkg/rancher-desktop/backend/backendHelper.ts ================================================ import path from 'path'; import Electron from 'electron'; import merge from 'lodash/merge'; import semver from 'semver'; import yaml from 'yaml'; import CERT_MANAGER from '@pkg/assets/scripts/cert-manager.yaml'; import INSTALL_CONTAINERD_SHIMS_SCRIPT from '@pkg/assets/scripts/install-containerd-shims'; import CONTAINERD_CONFIG from '@pkg/assets/scripts/k3s-containerd-config.toml'; import SPIN_OPERATOR from '@pkg/assets/scripts/spin-operator.yaml'; import { BackendSettings, VMExecutor } from '@pkg/backend/backend'; import { LockedFieldError } from '@pkg/config/commandLineOptions'; import { ContainerEngine, Settings } from '@pkg/config/settings'; import * as settingsImpl from '@pkg/config/settingsImpl'; import SettingsValidator from '@pkg/main/commandServer/settingsValidator'; import mainEvents from '@pkg/main/mainEvents'; import { minimumUpgradeVersion, SemanticVersionEntry } from '@pkg/utils/kubeVersions'; import Logging from '@pkg/utils/logging'; import paths from '@pkg/utils/paths'; import { jsonStringifyWithWhiteSpace } from '@pkg/utils/stringify'; import { showMessageBox } from '@pkg/window'; const CONTAINERD_CONFIG_TOML = '/etc/containerd/config.toml'; const DOCKER_DAEMON_JSON = '/etc/docker/daemon.json'; const MANIFEST_DIR = '/var/lib/rancher/k3s/server/manifests'; // Manifests are applied in sort order, so use a prefix to load them last, in the required sequence. // Names should start with `z` followed by a digit, so that `install-k3s` cleans them up on restart. export const MANIFEST_RUNTIMES = 'z100-runtimes'; export const MANIFEST_CERT_MANAGER_CRDS = 'z110-cert-manager.crds'; export const MANIFEST_CERT_MANAGER = 'z115-cert-manager'; export const MANIFEST_SPIN_OPERATOR_CRDS = 'z120-spin-operator.crds'; export const MANIFEST_SPIN_OPERATOR = 'z125-spin-operator'; const STATIC_DIR = '/var/lib/rancher/k3s/server/static/rancher-desktop'; const STATIC_CERT_MANAGER_CHART = `${ STATIC_DIR }/cert-manager.tgz`; const STATIC_SPIN_OPERATOR_CHART = `${ STATIC_DIR }/spin-operator.tgz`; const console = Logging.kube; export default class BackendHelper { /** * Workaround for upstream error https://github.com/containerd/nerdctl/issues/1308 * Nerdctl client (version 0.22.0 +) wants a populated auths field when credsStore gives credentials. * Note that we don't have to actually provide credentials in the value part of the `auths` field. * The code currently wants to see a `ServerURL` that matches the well-known docker hub registry URL, * even though it isn't needed, because at that point the code knows it's using the well-known registry. */ static ensureDockerAuth(existingConfig: Record): Record { return merge({ auths: { 'https://index.docker.io/v1/': {} } }, existingConfig); } /** * Replacer function for string.replaceAll(/(\\*)(")/g, this.escapeChar) * It will backslash-escape the specified character unless it is already * preceded by an odd number of backslashes. */ private static escapeChar(_: any, slashes: string, char: string) { if (slashes.length % 2 === 0) { slashes += '\\'; } return `${ slashes }${ char }`; } /** * Turn allowedImages patterns into a list of nginx regex rules. */ static createAllowedImageListConf(allowedImages: BackendSettings['containerEngine']['allowedImages']): string { /** * The image allow list config file consists of one line for each pattern using nginx pattern matching syntax. * It starts with '~*' for case-insensitive matching, followed by a regular expression, which should be * anchored to the beginning and end of the string with '^...$'. The pattern must be followed by ' 0;' and * a newline. The '0' means that this pattern is **not** forbidden (the table defaults to '1'). */ // TODO: remove hard-coded defaultSandboxImage from cri-dockerd let patterns = '"~*^registry\\.k8s\\.io(:443)?/v2/pause/manifests/[^/]+$" 0;\n'; // TODO: remove hardcoded CDN redirect target for registry.k8s.io patterns += '"~*^[^./]+\\.pkg\\.dev(:443)?/v2/.+/manifests/[^/]+$" 0;\n'; // TODO: remove hard-coded sandbox_image from our /etc/containerd/config.toml patterns += '"~*^registry-1\\.docker\\.io(:443)?/v2/rancher/mirrored-pause/manifests/[^/]+$" 0;\n'; for (const pattern of allowedImages.patterns) { let host = 'registry-1.docker.io'; // escape all unescaped double-quotes because the final pattern will be quoted to avoid nginx syntax errors let repo = pattern.replaceAll(/(\\*)(")/g, this.escapeChar).split('/'); // no special cases for 'localhost' and 'host-without-dot:port'; they won't work within the VM if (repo[0].includes('.')) { host = repo.shift()!; if (host === 'docker.io') { host = 'registry-1.docker.io'; // 'docker.io/busybox' means 'registry-1.docker.io/library/busybox' if (repo.length === 1) { repo.unshift('library'); } } // registry without repo is the same as 'registry//' if (repo.length === 0) { repo = ['', '']; } } else if (repo.length < 2) { repo.unshift('library'); } // all dots in the host name are literal dots, but don't escape them if they are already escaped host = host.replaceAll(/(\\*)(\.)/g, this.escapeChar); // matching against http_host header, which may or may not include the port if (!host.includes(':')) { host += '(:443)?'; } // match for "image:tag@digest" (tag and digest are both optional) const match = /^(?.*?)(:(?.*?))?(@(?.*))?$/.exec(repo[repo.length - 1]); let tag = '[^/]+'; // Strip tag and digest from last fragment of the image name. // `match` and `match.groups` can't be `null` because the regular expression will match the empty string, // but TypeScript can't know that. if (match?.groups?.tag || match?.groups?.digest) { repo.pop(); repo.push(match.groups.image); // actual tag is ignored when a digest is specified tag = match.groups.digest || match.groups.tag; } // special wildcard rules: 'foo//' means 'foo/.+' and 'foo/' means 'foo/[^/]+' if (repo[repo.length - 1] === '') { repo.pop(); if (repo.length > 0 && repo[repo.length - 1] === '') { repo.pop(); repo.push('.+'); } else { repo.push('[^/]+'); } } patterns += `"~*^${ host }/v2/${ repo.join('/') }/manifests/${ tag }$" 0;\n`; } return patterns; } static requiresCRIDockerd(engineName: string, kubeVersion: string | semver.SemVer): boolean { if (engineName !== ContainerEngine.MOBY) { return false; } const ranges = [ // versions 1.24.1 to 1.24.3 don't support the --docker option '1.24.1 - 1.24.3', // cri-dockerd bundled with k3s is not compatible with docker 25.x (using API 1.44) // see https://github.com/k3s-io/k3s/issues/9279 '1.26.8 - 1.26.13', '1.27.5 - 1.27.10', '1.28.0 - 1.28.6', '1.29.0 - 1.29.1', ]; return semver.satisfies(kubeVersion, ranges.join('||')); } static checkForLockedVersion(newVersion: semver.SemVer, cfg: BackendSettings, sv: SettingsValidator): void { const [, errors] = sv.validateSettings(cfg as Settings, { kubernetes: { version: newVersion.raw } }, settingsImpl.getLockedSettings()); if (errors.length > 0) { if (errors.some(err => /field ".*" is locked/.exec(err))) { throw new LockedFieldError(`Error in deployment profiles:\n${ errors.join('\n') }`); } else { throw new Error(`Validation errors for requested version ${ newVersion }: ${ errors.join('\n') }`); } } } /** * Validate the cfg.kubernetes.version string * If it's valid and available, use it. * Otherwise fall back to the minimum upgrade version (highest patch release of lowest available version). */ static async getDesiredVersion(cfg: BackendSettings, availableVersions: SemanticVersionEntry[], noModalDialogs: boolean, settingsWriter: (_: any) => void): Promise { const currentConfigVersionString = cfg?.kubernetes?.version; let storedVersion: semver.SemVer | null; let matchedVersion: SemanticVersionEntry | undefined; const invalidK8sVersionMainMessage = `Requested kubernetes version '${ currentConfigVersionString }' is not a supported version.`; const sv = new SettingsValidator(); const lockedSettings = settingsImpl.getLockedSettings(); const versionIsLocked = lockedSettings.kubernetes?.version ?? false; // If we're here either there's no existing cfg.k8s.version, or it isn't valid if (!availableVersions.length) { if (currentConfigVersionString) { console.log(invalidK8sVersionMainMessage); } else { console.log('Internal error: no available kubernetes versions found.'); } throw new Error('No kubernetes version available.'); } const upgradeVersion = minimumUpgradeVersion(availableVersions); if (!upgradeVersion) { // This should never be reached, as `availableVersions` isn't empty. throw new Error('Failed to find upgrade version.'); } sv.k8sVersions = availableVersions.map(v => v.version.version); if (currentConfigVersionString) { storedVersion = semver.parse(currentConfigVersionString); if (storedVersion) { matchedVersion = availableVersions.find((v) => { try { return semver.eq(v.version, storedVersion!); } catch (err: any) { console.error(`Can't compare versions ${ storedVersion } and ${ v }: `, err); if (!(err instanceof TypeError)) { return false; } // We haven't seen a non-TypeError exception here, but it would be worthwhile to have it reported. // This throw will cause the exception to appear in a non-fatal error reporting dialog box. throw err; } }); if (matchedVersion) { // This throws a LockedFieldError if it fails. this.checkForLockedVersion(matchedVersion.version, cfg, sv); return matchedVersion.version; } else if (versionIsLocked) { // This is a bit subtle. If we're here, the user specified a nonexistent version in the locked manifest. // We can't switch to the default version, so throw a fatal error. throw new LockedFieldError(`Locked kubernetes version ${ currentConfigVersionString } isn't available.`); } } else if (versionIsLocked) { // If we're here, the user specified a non-version in the locked manifest. // We can't switch to the default version, so throw a fatal error. throw new LockedFieldError(`Locked kubernetes version '${ currentConfigVersionString }' isn't a valid version.`); } const message = invalidK8sVersionMainMessage; const detail = `Falling back to recommended minimum upgrade version of ${ upgradeVersion.version.version }`; if (noModalDialogs) { console.log(`${ message } ${ detail }`); } else { const options: Electron.MessageBoxOptions = { message, detail, type: 'warning', buttons: ['OK'], title: 'Invalid Kubernetes Version', }; await showMessageBox(options, true); } } // No (valid) stored version; save the default one. // Because no version was specified, there can't be a locked version field, so no need to call checkForLockedVersion. settingsWriter({ kubernetes: { version: upgradeVersion.version.version } }); return upgradeVersion.version; } /** * Return a dictionary of all containerd shims installed in /usr/local/bin. * Keys are the shim names and values are the filenames. */ static async containerdShims(vmx: VMExecutor): Promise> { const shims: Record = {}; try { const files = await vmx.execCommand({ capture: true }, '/bin/ls', '-1', '-p', '/usr/local/bin'); for (const file of files.split(/\n/)) { const match = /^containerd-shim-([-a-z]+)-v\d+$/.exec(file); if (match) { shims[match[1]] = file; } } } catch (e: any) { console.log('containerdShims: Got exception:', e); throw e; } return shims; } private static manifestFilename(manifest: string): string { return `${ MANIFEST_DIR }/${ manifest }.yaml`; } /** * Write a k3s manifest to define a runtime class for each installed containerd shim. */ static async configureRuntimeClasses(vmx: VMExecutor) { const runtimes = []; for (const shim in await BackendHelper.containerdShims(vmx)) { runtimes.push({ apiVersion: 'node.k8s.io/v1', kind: 'RuntimeClass', metadata: { name: shim }, handler: shim, }); } // Don't let k3s define runtime classes, only use the ones defined by Rancher Desktop. await vmx.execCommand({ root: true }, 'touch', `${ MANIFEST_DIR }/runtimes.yaml.skip`); if (runtimes.length > 0) { const manifest = runtimes.map(r => yaml.stringify(r)).join('---\n'); await vmx.writeFile(this.manifestFilename(MANIFEST_RUNTIMES), manifest, 0o644); } } /** * Write k3s manifests to install cert-manager and spinkube operator */ static async configureSpinOperator(vmx: VMExecutor) { await Promise.all([ vmx.copyFileIn(path.join(paths.resources, 'cert-manager.crds.yaml'), this.manifestFilename(MANIFEST_CERT_MANAGER_CRDS)), vmx.copyFileIn(path.join(paths.resources, 'cert-manager.tgz'), STATIC_CERT_MANAGER_CHART), vmx.writeFile(this.manifestFilename(MANIFEST_CERT_MANAGER), CERT_MANAGER, 0o644), vmx.copyFileIn(path.join(paths.resources, 'spin-operator.crds.yaml'), this.manifestFilename(MANIFEST_SPIN_OPERATOR_CRDS)), vmx.copyFileIn(path.join(paths.resources, 'spin-operator.tgz'), STATIC_SPIN_OPERATOR_CHART), vmx.writeFile(this.manifestFilename(MANIFEST_SPIN_OPERATOR), SPIN_OPERATOR, 0o644), ]); } /** * Install containerd-wasm shims into /usr/local/containerd-shims (and symlinks into /usr/local/bin). */ static async installContainerdShims(vmx: VMExecutor, configureWASM: boolean) { // Calling install-containerd-shims without source dirs will remove the symlinks from /usr/local/bin. const sourceDirs: string[] = []; if (configureWASM) { sourceDirs.push( // Copy shims bundled with the app itself first, user-managed shims may override. path.join(paths.resources, 'linux', 'internal'), paths.containerdShims, ); } await vmx.execCommand({ root: true }, 'mkdir', '-p', '/root'); await vmx.writeFile('/root/install-containerd-shims', INSTALL_CONTAINERD_SHIMS_SCRIPT, 'a+x'); await vmx.execCommand({ root: true }, '/root/install-containerd-shims', ...sourceDirs); } /** * Write the containerd config file. If WASM is enabled, include a runtime definition * for each installed containerd shim. */ static async writeContainerdConfig(vmx: VMExecutor, configureWASM: boolean): Promise { let config = CONTAINERD_CONFIG; if (configureWASM) { const shims = await BackendHelper.containerdShims(vmx); for (const shim in shims) { config += '\n'; config += `[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.${ shim }]\n`; config += ` runtime_type = "/usr/local/bin/${ shims[shim] }"\n`; } } await vmx.writeFile(CONTAINERD_CONFIG_TOML, config); } /** * Configure the Moby containerd-snapshotter feature if WASM support is * requested, or if we have not previously run the daemon. */ static async configureMobyStorage(vmx: VMExecutor, storageDriver: 'classic' | 'snapshotter' | 'auto', configureWASM: boolean) { // Due to issues with a botched migration, we will need to provide more logic // to determine whether to use the containerd-snapshotter backend for moby // storage. See https://github.com/rancher-sandbox/rancher-desktop/issues/9732 // for more details. // If this directory is not empty, we assume that there is data in the containerd snapshotter. const snapshotterDir = '/var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/'; // If this directory is not empty, we assume that there is data in the classic storage. const classicDir = '/var/lib/docker/image/overlay2/imagedb/content/sha256/'; // no-spell-check let useSnapshotter: boolean | undefined; // Check if a directory (in the VM) has any subdirectories or files. async function dirHasChildren(dir: string): Promise { try { const stdout = await vmx.execCommand( { root: true, expectFailure: true, capture: true }, '/usr/bin/find', dir, '-maxdepth', '0', '-not', '-empty'); return stdout.trim().length > 0; } catch { // Directory does not exist. return false; } } const hasSnapshotterData = await dirHasChildren(snapshotterDir); const hasClassicData = await dirHasChildren(classicDir); // If `storageDriver` is explicitly set, use that setting. if (storageDriver !== 'auto') { useSnapshotter = (storageDriver === 'snapshotter'); } else if (configureWASM) { // WASM requires the containerd snapshotter. useSnapshotter = true; } else if (hasSnapshotterData) { // If there is data in the containerd snapshotter store, use it. useSnapshotter = true; } else { // If there is no data in the classic storage, use containerd snapshotter. useSnapshotter = !hasClassicData; } mainEvents.emit('diagnostics-event', { id: 'moby-storage', hasClassicData, hasSnapshotterData, useSnapshotter, }); let config: Record; try { config = JSON.parse(await vmx.readFile(DOCKER_DAEMON_JSON)); } catch (err: any) { await vmx.execCommand({ root: true }, 'mkdir', '-p', path.dirname(DOCKER_DAEMON_JSON)); config = {}; } config['min-api-version'] = '1.41'; config['features'] ??= {}; config['features']['containerd-snapshotter'] = useSnapshotter; if (config['features']['containerd-snapshotter']) { // If we are using the containerd snapshotter, create /var/lib/docker/image // to avoid breaking cri-dockerd. await vmx.execCommand({ root: true }, 'mkdir', '-p', '/var/lib/docker/image'); } await vmx.writeFile(DOCKER_DAEMON_JSON, jsonStringifyWithWhiteSpace(config), 0o644); } static async configureContainerEngine(vmx: VMExecutor, configureWASM: boolean, mobyStorageDriver: 'classic' | 'snapshotter' | 'auto') { await BackendHelper.installContainerdShims(vmx, configureWASM); await BackendHelper.writeContainerdConfig(vmx, configureWASM); await BackendHelper.configureMobyStorage(vmx, mobyStorageDriver, configureWASM); } } ================================================ FILE: pkg/rancher-desktop/backend/containerClient/__tests__/auth.spec.ts ================================================ /** @jest-environment node */ import { jest } from '@jest/globals'; import * as childProcess from '@pkg/utils/childProcess'; import mockModules from '@pkg/utils/testUtils/mockModules'; const modules = mockModules({ '@pkg/utils/childProcess': { ...childProcess, spawnFile: jest.fn(childProcess.spawnFile), }, electron: undefined, }); const { default: RegistryAuth } = await import('@pkg/backend/containerClient/auth'); describe('RegistryAuth', () => { describe('parseAuthHeader', () => { const testCases: { input: string, expected: { scheme: string, parameters?: Record }[], }[] = [ { input: '', expected: [] }, { input: 'Basic', expected: [{ scheme: 'basic' }], }, { input: 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io"', expected: [{ scheme: 'bearer', parameters: { realm: 'https://auth.docker.io/token', service: 'registry.docker.io' } }], }, { input: 'one,two,three', expected: [{ scheme: 'one' }, { scheme: 'two' }, { scheme: 'three' }], }, { input: 'broken quotes="value starts but never ends', expected: [{ scheme: 'broken', parameters: { quotes: 'value starts but never ends' } }], }, { input: 'Token one=1,two=2, Other three="3", four="4"', expected: [{ scheme: 'token', parameters: { one: '1', two: '2' } }, { scheme: 'other', parameters: { three: '3', four: '4' } }], }, { input: 'parameter=unused, token', expected: [{ scheme: 'token' }], }, { input: 'token parameter=, other', expected: [{ scheme: 'token', parameters: { parameter: '' } }, { scheme: 'other' }], }, { // From RFC 9110, section 11.6.1 input: 'Basic realm="simple", Newauth realm="apps", type=1, title="Login to \\"apps\\""', expected: [ { scheme: 'basic', parameters: { realm: 'simple' } }, { scheme: 'newauth', parameters: { realm: 'apps', type: '1', title: 'Login to "apps"', }, }, ], }, ]; test.each(testCases)('$#: $input', ({ input, expected }) => { const actual = RegistryAuth['parseAuthHeader'](input); expect(actual).toEqual(expected.map(v => ({ parameters: {}, ...v }))); }); }); describe('findAuth', () => { it('should not fail when failing to list known credentials', async() => { const exception = new Error('failed to spawn file'); modules['@pkg/utils/childProcess'].spawnFile.mockRejectedValue(exception); await expect(RegistryAuth['findAuth']('example.test')).resolves.toBeUndefined(); }); }); }); ================================================ FILE: pkg/rancher-desktop/backend/containerClient/__tests__/client.spec.ts ================================================ /** @jest-environment node */ import { jest } from '@jest/globals'; import { ContainerEngineClient } from '@pkg/backend/containerClient/types'; import mockModules from '@pkg/utils/testUtils/mockModules'; const modules = mockModules({ '@pkg/backend/mock': { default: jest.fn(), }, '@pkg/backend/containerClient/registry': { default: { getTags: jest.fn((_name: string) => Promise.resolve([])), }, }, electron: undefined, }); const { default: MockBackend } = await import('@pkg/backend/mock'); const { NerdctlClient } = await import('@pkg/backend/containerClient/nerdctlClient'); const { MobyClient } = await import('@pkg/backend/containerClient/mobyClient'); describe.each(['nerdctl', 'moby'] as const)('%s', (clientName) => { let subject: ContainerEngineClient; beforeEach(() => { const executor = new MockBackend() as jest.Mocked>; switch (clientName) { case 'nerdctl': subject = new NerdctlClient(executor); break; case 'moby': subject = new MobyClient(executor, ''); break; default: throw new Error(`Unexpected client name ${ clientName }`); } }); describe('getTags', () => { const repository = 'registry.test/name'; let registryTags: string[]; let localTags: string[]; let localExtras: string[]; beforeEach(() => { registryTags = []; localTags = []; localExtras = []; modules['@pkg/backend/containerClient/registry'].default.getTags.mockImplementation((name) => { expect(name).toEqual(repository); if (registryTags.length) { return Promise.resolve(registryTags); } return Promise.reject('Could not get tags from registry'); }); jest.spyOn(subject, 'runClient').mockImplementation((args, stdio) => { expect(args).toEqual(expect.arrayContaining(['image', 'list'])); expect(stdio).toEqual('pipe'); const results: string[] = []; if (localTags.length) { results.push(...localTags.map(t => `${ repository }:${ t }`)); } if (localExtras.length) { results.push(...localExtras); } if (results.length) { return Promise.resolve({ stdout: results.join('\n') }); } // We need the cast to any because `runClient()` is overloaded and // it's hard to convince TypeScript that the return value is fine. return Promise.reject('Could not get tags locally') as any; }); }); afterEach(() => { jest.restoreAllMocks(); jest.resetAllMocks(); }); it('should list tags from the registry', async() => { registryTags = ['apple', 'banana']; await expect(subject.getTags(repository)).resolves.toEqual(new Set(registryTags)); }); it('should list local tags', async() => { localTags = ['carrot', 'durian']; localExtras = ['irrelevant:grape', 'registry.invalid/other:honeydew']; await expect(subject.getTags(repository)).resolves.toEqual(new Set(localTags)); }); it('should merge tags', async() => { registryTags = ['jackfruit', 'kiwi']; localTags = ['kiwi', 'lemon']; await expect(subject.getTags(repository)).resolves.toEqual(new Set([...registryTags, ...localTags])); }); it('should ignore errors', async() => { await expect(subject.getTags(repository)).resolves.toEqual(new Set()); }); }); }); ================================================ FILE: pkg/rancher-desktop/backend/containerClient/__tests__/registry.spec.ts ================================================ /** @jest-environment node */ import mockModules from '@pkg/utils/testUtils/mockModules'; const modules = mockModules({ electron: undefined }); const { default: dockerRegistry } = await import('@pkg/backend/containerClient/registry'); describe('DockerRegistry', () => { beforeEach(() => { // We need to send actual network requests in this test. modules.electron.net.fetch.mockImplementation(fetch); }); describe('getTags', () => { it.skip('should get tags from unauthenticated registry', async() => { // Sometimes this URL is broken, returning 504 Gateway Time-out // It shouldn't be used for a unit test anyway. const reference = 'registry.opensuse.org/opensuse/leap'; await expect(dockerRegistry.getTags(reference)) .resolves .toEqual(expect.arrayContaining(['15.4'])); }); it('should get tags from docker hub', async() => { await expect(dockerRegistry.getTags('hello-world')) .resolves .toEqual(expect.arrayContaining(['linux'])); }); it('should fail trying to get tags from invalid registry', async() => { await expect(dockerRegistry.getTags('host.invalid/name')) .rejects .toThrow(); }); }); }); ================================================ FILE: pkg/rancher-desktop/backend/containerClient/auth.ts ================================================ import { net } from 'electron'; import runCredentialCommand from '@pkg/main/credentialServer/credentialUtils'; import Logging from '@pkg/utils/logging'; const console = Logging.background; interface tokenCacheEntry { /** The expiry of this token, as milliseconds since Unix epoch. */ expiry: number; /** The raw token. */ token: string; } /** * RegistryAuth handles HTTP authentication for the registries */ class RegistryAuth { /** * A cache of still-valid tokens, keyed by (registry) host. */ protected tokenCache: Record = {}; /** * Ask the credential helpers for authentication for the given host. * @param hosts The hosts to find auth for; possibly a URL instead. * @returns The value of the `Authorization` header to use. */ protected async findAuth(...hosts: string[]): Promise { const candidates: string[] = []; const hostCandidates: string[] = []; const suffixes = ['/', '']; for (const host of hosts) { if (!host) { continue; } if (host.includes('://')) { // This is a full URL, parse it. const url = new URL(host); candidates.push(host); hostCandidates.push(url.host, url.hostname); suffixes.push(url.pathname); } else { hostCandidates.push(host); } } if (hostCandidates.some(h => h === 'docker.io' || h.endsWith('.docker.io'))) { // Special handling for docker (typically, `https://auth.docker.io/token`). hostCandidates.push('index.docker.io'); suffixes.push('/v1/'); } for (const protocol of ['http', 'https']) { for (const hostPart of hostCandidates) { for (const suffix of suffixes) { candidates.push(`${ protocol }://${ hostPart }${ suffix }`); } } } let knownAuths: Record; try { knownAuths = JSON.parse(await runCredentialCommand('list')); } catch (ex) { // if we fail to list credentials, that's not an error (there's probably // no docker config or something). console.debug(`Failed to list known credentials: ${ ex }`); return; } for (const candidate of candidates) { if (candidate in knownAuths) { try { const auth = JSON.parse(await runCredentialCommand('get', candidate)); const login = Buffer.from(`${ auth.Username }:${ auth.Secret }`, 'utf-8'); return `Basic ${ login.toString('base64') }`; } catch { // Failure to get credentials from one helper isn't fatal. continue; } } } } /** * HTTP Basic Authentication */ protected async basicAuth(host: string): Promise> { const auth = await this.findAuth(host); if (auth) { return { Authorization: auth }; } throw new Error(`Could not find auth for ${ host }`); } /** * HTTP Bearer Authentication * @param host The host we're trying to authenticate against * @param parameters The WWW-Authenticate header parameters. */ protected async bearerAuth(host: string, parameters: Record): Promise> { // If we have a token in the cache, return it. if (host in this.tokenCache) { const cachedToken = this.tokenCache[host]; if (cachedToken.expiry > Date.now()) { return { Authorization: `Bearer ${ cachedToken.token }` }; } delete this.tokenCache[host]; } const url = new URL(parameters.realm ?? (host.includes('://') ? host : `https://${ host }`)); const auth = await this.findAuth(parameters.realm, host); const headers: HeadersInit = auth ? { Authorization: auth } : {}; if (parameters.service) { url.searchParams.set('service', parameters.service); } if (parameters.scope) { for (const scope of parameters.scope.split(/\s+/)) { url.searchParams.append('scope', scope); } } const resp = await net.fetch(url.toString(), { headers }); if (!resp.ok) { throw new Error(`Could not get authorization token from ${ url } (for ${ url }): ${ JSON.stringify(resp) }`); } let result: any; try { result = await resp.json(); } catch (ex) { const error = new Error(`Failed to parse authorization response`); (error as any).cause = ex; throw error; } const parsed = { token: result.token || result.access_token, issued_at: result.issued_at ?? (new Date()).toISOString(), expires_in: result.expires_in ?? 300, }; type parsedKey = keyof typeof parsed; const types: Record = { token: 'string', issued_at: 'string', expires_in: 'number', }; let issuedDate: number; for (const [k, type] of Object.entries(types) as [parsedKey, typeof types[parsedKey]][]) { // eslint-disable-next-line valid-typeof -- The set is hard-coded. if (typeof parsed[k] !== type) { throw new TypeError(`Failed to read authorization response: ${ k } is not a ${ type } (${ typeof parsed[k] })`); } } try { issuedDate = Date.parse(parsed.issued_at); } catch (ex) { const error = new Error(`Failed to parse authorization response issued_at ${ parsed.issued_at }`); (error as any).cause = ex; throw error; } this.tokenCache[host] = { expiry: issuedDate + parsed.expires_in * 1_000, token: parsed.token, }; return { Authorization: `Bearer ${ parsed.token }` }; } protected parseAuthHeader(header: string): { scheme: string, parameters: Record }[] { // This header is a bit tricky (hence a separate method for testing): // The header may contain multiple comma-separated challenge specifications, // each of which consists of one word ("scheme") plus zero or more comma- // separated parameters for that scheme. Parameters may have quoted values // which may internally contain commas. const results: { scheme: string, parameters: Record }[] = []; let scheme = ''; let parameters: Record = {}; function push() { if (scheme) { results.push({ scheme, parameters }); parameters = {}; } } header = header.trim(); // From now on, `header` should never have leading/trailing whitespace. while (header) { const posMapping = { space: /\s/.exec(header)?.index ?? -1, equal: header.indexOf('='), comma: header.indexOf(','), end: header.length, } as const; const posList = (Object.entries(posMapping) as [keyof typeof posMapping, number][]) .filter(([, v]) => v >= 0) .sort(([, l], [, r]) => l - r); const [type, pos] = posList[0]; switch (type) { case 'equal': { // An equals sign precedes any spaces etc.; this is a parameter. const key = header.substring(0, pos); let value = ''; header = header.substring(pos + 1).trimStart(); if (header.startsWith('"')) { let quoteEnded = false; header = header.substring(1); while (!quoteEnded) { const quotePosMapping = { backslash: header.indexOf('\\'), quote: header.indexOf('"'), end: header.length, } as const; const quotePosList = (Object.entries(quotePosMapping) as [keyof typeof quotePosMapping, number][]) .filter(([, v]) => v >= 0) .sort(([, l], [, r]) => l - r); const [quoteType, quotePos] = quotePosList[0]; switch (quoteType) { case 'backslash': { // We can get away with just treating the next character as // a literal (no `\n` for newline, etc.). value += header.substring(0, quotePos); header = header.substring(quotePos + 1); if (header) { value += header.substring(0, 1); header = header.substring(1); } break; } case 'quote': { value += header.substring(0, quotePos); header = header.substring(quotePos + 1).replace(/^[,\s]*/, ''); quoteEnded = true; break; } case 'end': { // Could not find end of quote value += header; header = ''; quoteEnded = true; break; } } } } else { // This value is not quoted const commaPos = header.indexOf(','); if (commaPos < 0) { // No comma, the parameter runs to the end of the header. value = header; header = ''; } else { value = header.substring(0, commaPos); header = header.substring(commaPos).replace(/^[,\s]*/, ''); } } if (scheme) { // Only allow adding parameters if we already found a scheme. parameters[key] = value; } break; } case 'space': { // A space precedes any equals signs; this is a scheme. push(); scheme = header.substring(0, pos).toLowerCase(); header = header.substring(pos).replace(/^[,\s]*/, ''); break; } case 'end': { // Neither space nor equal found. // This is a bare scheme. push(); scheme = header.trim().toLowerCase(); header = header.substring(scheme.length).trim(); break; } case 'comma': { // This is a bare scheme. push(); scheme = header.substring(0, pos); header = header.substring(pos + 1).replace(/^[,\s]*/, ''); } } } push(); return results; } /** * Determine authentication required. * @param endpoint The endpoint to use to test for authentication requirements. * @returns The headers needed for authentication. */ async authenticate(endpoint: URL): Promise { if (endpoint.host in this.tokenCache) { // If we have a valid cached token, use it directly. const cachedToken = this.tokenCache[endpoint.host]; if (cachedToken.expiry > Date.now()) { return new Headers({ Authorization: `Bearer ${ cachedToken.token }` }); } } const resp = await net.fetch(endpoint.toString()); if (resp.status !== 401) { console.debug(`${ endpoint } does not require authentication`); return new Headers(); } const authenticateHeader = resp.headers.get('WWW-Authenticate') ?? ''; for (const challenge of this.parseAuthHeader(authenticateHeader)) { if (challenge.scheme === 'basic') { try { return new Headers(await this.basicAuth(endpoint.toString())); } catch (ex) { console.debug(`Could not do Basic authentication for ${ endpoint }`, ex); } } else if (challenge.scheme === 'bearer') { try { return new Headers(await this.bearerAuth(endpoint.toString(), challenge.parameters)); } catch (ex) { console.debug(`Could not do Bearer authentication for ${ endpoint }:`, ex); } } else { console.debug(`Don't know how to do ${ challenge.scheme } authentication for ${ endpoint }, skipping`); } } // If we reach here, we got a HTTP 401, but couldn't figure out how to do // authentication. throw new Error(`Failed to find compatible authentication scheme for ${ endpoint }`); } } const auth = new RegistryAuth(); export default auth; ================================================ FILE: pkg/rancher-desktop/backend/containerClient/index.ts ================================================ export * from './types'; export { MobyClient } from './mobyClient'; export { NerdctlClient } from './nerdctlClient'; ================================================ FILE: pkg/rancher-desktop/backend/containerClient/mobyClient.ts ================================================ import fs from 'fs'; import os from 'os'; import path from 'path'; import stream from 'stream'; import util from 'util'; import _ from 'lodash'; import tar from 'tar-stream'; import { ContainerComposeExecOptions, ReadableProcess, WritableReadableProcess, ContainerComposeOptions, ContainerEngineClient, ContainerRunOptions, ContainerStopOptions, ContainerRunClientOptions, ContainerComposePortOptions, ContainerBasicOptions, } from './types'; import { VMExecutor } from '@pkg/backend/backend'; import dockerRegistry from '@pkg/backend/containerClient/registry'; import { ErrorCommand, spawn, spawnFile } from '@pkg/utils/childProcess'; import { parseImageReference } from '@pkg/utils/dockerUtils'; import Logging, { Log } from '@pkg/utils/logging'; import paths from '@pkg/utils/paths'; import { executable } from '@pkg/utils/resources'; import { defined } from '@pkg/utils/typeUtils'; const console = Logging.moby; type runClientOptions = ContainerRunClientOptions & { /** The executable to run, defaulting to this.executable (i.e. "docker") */ executable?: string; }; export class MobyClient implements ContainerEngineClient { constructor(vm: VMExecutor, endpoint: string) { this.vm = vm; this.endpoint = endpoint; } readonly vm: VMExecutor; readonly executable = executable('docker'); readonly endpoint: string; /** * Run a list of cleanup functions in reverse. */ protected async runCleanups(cleanups: (() => Promise)[]) { for (const cleanup of cleanups.reverse()) { try { await cleanup(); } catch (e) { console.error('Failed to run cleanup:', e); } } } protected async makeContainer(imageID: string): Promise { const { stdout, stderr } = await this.runClient(['create', '--entrypoint=/', imageID], 'pipe'); const container = stdout.trim(); console.debug(stderr.trim()); if (!container) { throw new Error(`Failed to create container ${ imageID }`); } return container; } async waitForReady(): Promise { let successCount = 0; let failureCount = 0; let lastOutput = { stdout: '', stderr: '' }; // Wait for ten consecutive successes, clearing out successCount whenever we // hit an error. In the ideal case this is a five-second delay in startup // time. We use `docker system info` because that needs to talk to the // socket to fetch data about the engine (and it returns an error if it // fails to do so). while (successCount < 10) { try { await this.runClient(['system', 'info'], 'pipe'); successCount++; failureCount = 0; } catch (ex) { successCount = 0; failureCount++; // If we've been erroring for a while, log the output. if (failureCount > 10 && ex && typeof ex === 'object') { const output = { stdout: '', stderr: '' }; if ('stdout' in ex && typeof ex.stdout === 'string') { output.stdout = ex.stdout; } if ('stderr' in ex && typeof ex.stderr === 'string') { output.stderr = ex.stderr; } if (output.stdout !== lastOutput.stdout || output.stderr !== lastOutput.stderr) { console.error(`Failed to run docker system info after ${ failureCount } failures (will retry):`, output); lastOutput = output; } } } await util.promisify(setTimeout)(500); } } readFile(imageID: string, filePath: string): Promise; readFile(imageID: string, filePath: string, options: { encoding?: BufferEncoding; }): Promise; async readFile(imageID: string, filePath: string, options?: { encoding?: BufferEncoding }): Promise { const encoding = options?.encoding ?? 'utf-8'; console.debug(`Reading file ${ imageID }:${ filePath }`); const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-moby-readfile-')); const tempFile = path.join(workDir, path.basename(filePath)); // `docker cp ... -` returns a tar file, which isn't what we want. It's // easiest to just copy the file to disk and read it. try { await this.copyFile(imageID, filePath, workDir, { silent: true }); return await fs.promises.readFile(tempFile, { encoding }); } finally { await fs.promises.rm(workDir, { recursive: true, maxRetries: 3 }); } } copyFile(imageID: string, sourcePath: string, destinationPath: string): Promise; copyFile(imageID: string, sourcePath: string, destinationPath: string, options: { silent?: true }): Promise; async copyFile(imageID: string, sourcePath: string, destinationPath: string, options?: { silent?: boolean }): Promise { const cleanups: (() => Promise)[] = []; if (!options?.silent) { console.debug(`Copying ${ imageID }:${ sourcePath } to ${ destinationPath }`); } const container = await this.makeContainer(imageID); cleanups.push(() => this.runClient(['rm', container], console)); try { if (this.vm.backend === 'wsl') { // On Windows, non-Administrators by default do not have the privileges // to create symlinks. However, `docker cp --follow-link` doesn't // dereference symlinks it encounters when recursively copying a file. // We work around this by copying it into a tarball in the VM and then // extracting it from there. const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-moby-cp-')); cleanups.push(() => fs.promises.rm(workDir, { recursive: true, force: true, maxRetries: 3, })); const archive = path.join(workDir, 'archive.tar'); const wslArchive = (await this.vm.execCommand({ capture: true }, '/bin/wslpath', '-u', archive)).trim(); await this.vm.execCommand( '/bin/sh', '-c', `/usr/bin/docker cp '${ container }:${ sourcePath }' - > '${ wslArchive }'`); if (sourcePath.endsWith('/')) { await this.extractArchive(archive, destinationPath, sourcePath); } else { // If we only archived a single file, there is no prefix in the archive. await this.extractArchive(archive, destinationPath); } } else { if (sourcePath.endsWith('/')) { // If we're copying a directory, add "." so we don't create an extra // directory. sourcePath += '.'; } await this.runClient( ['cp', '--follow-link', `${ container }:${ sourcePath }`, destinationPath], console); } } finally { await this.runCleanups(cleanups); } } /** * Extract the given archive into the given directory, dereferencing symbolic * links (because they are not supported on Windows). * @param archive The archive to extract, as a host path. * @param destination The destination directory, as a host path. * @param stripPrefix A prefix to strip from the file path. */ protected async extractArchive(archive: string, destination: string, stripPrefix = ''): Promise { const stripPrefixWithoutSlash = stripPrefix.replace(/^\/+/, ''); // Because tar is a streaming format, we need to go over it twice: first, to // extract the non-linked files, and to collect all links; then again, to // extract any files that were pointed to by links. const links: Record = {}; // Convert a given path to an absolute path, ensuring that it resides // within the destination. If the name does not start with the prefix to be // stripped, returns `undefined` and this entry should not be processed. const absPath = (rawPath: string): string | undefined => { let mungedPath = rawPath; if (stripPrefix) { if (mungedPath.startsWith(stripPrefixWithoutSlash)) { mungedPath = mungedPath.substring(stripPrefixWithoutSlash.length); } else { // A prefix is given, but we found a file that doesn't match; we // should skip this file. return; } } const normalized = path.normalize(path.join(destination, mungedPath)); if (/[/\\]\.\.[/\\]/.test(path.relative(destination, normalized))) { throw new Error(`Error extracting archive: ${ normalized } is not in ${ destination }`); } return normalized; }; for await (const entry of fs.createReadStream(archive).pipe(tar.extract())) { switch (entry.header.type) { case 'link': case 'symlink': { const linkName = entry.header.name; const realName = entry.header.linkname; if (!realName) { throw new Error(`Error extracting archive: ${ linkName } has no destination`); } if (realName.startsWith('/')) { links[linkName] = realName; } else { links[linkName] = path.posix.join(path.posix.dirname(entry.header.name), realName); } await stream.promises.finished(entry.resume() as any); break; } case 'directory': { const dirName = absPath(entry.header.name); if (!dirName) { console.warn(`Skipping unexpected directory ${ entry.header.name }`); continue; } await fs.promises.mkdir(dirName, { recursive: true }); await stream.promises.finished(entry.resume() as any); console.debug(`Created directory ${ dirName }`); break; } case 'file': case 'contiguous-file': { const fileName = absPath(entry.header.name); if (!fileName) { console.warn(`Skipping unexpected file ${ entry.header.name }`); continue; } await fs.promises.mkdir(path.dirname(fileName), { recursive: true }); await stream.promises.finished(entry.pipe(fs.createWriteStream(fileName))); console.debug(`Wrote ${ fileName }`); break; } default: console.info(`Ignoring unsupported file type ${ entry.header.name } (${ entry.header.type })`); } } /** * Mapping from link destination to the link name. * @note There can be multiple links pointing to the same file. */ const reverseLinks: Record = {}; for (const linkName in links) { while (links[links[linkName]] && links[linkName] !== linkName) { // The link points to another link; flatten it. links[linkName] = links[links[linkName]]; } reverseLinks[links[linkName]] ||= []; reverseLinks[links[linkName]].push(linkName); } if (Object.keys(reverseLinks).length === 0) { return; } for await (const entry of fs.createReadStream(archive).pipe(tar.extract())) { const linkNames = reverseLinks[entry.header.name] ?? []; if (linkNames.length === 0) { // This entry isn't a link target await stream.promises.finished(entry.resume() as any); continue; } switch (entry.header.type) { case 'directory': await Promise.all(linkNames.map(async(linkName) => { const dirName = absPath(linkName); if (!dirName) { console.warn(`Skipping unexpected directory ${ entry.header.name } -> ${ linkName }`); return; } await fs.promises.mkdir(dirName, { recursive: true }); delete links[linkName]; console.debug(`Created directory ${ dirName }`); })); break; case 'file': case 'contiguous-file': { const fileNames = linkNames.map((linkName) => { const fileName = absPath(linkName); if (!fileName) { console.warn(`Skipping unexpected file ${ entry.header.name } -> ${ linkName }`); } return fileName; }).filter(defined); await Promise.all(fileNames.map((fileName) => { return fs.promises.mkdir(path.dirname(fileName), { recursive: true }); })); const writers = fileNames.map(f => fs.createWriteStream(f)); entry.on('data', async(chunk) => { entry.pause(); try { await Promise.all(writers.map(async(writer) => { await new Promise((resolve, reject) => { writer.write(chunk, 'utf-8', (error) => { if (error) { reject(error); } else { resolve(); } }); }); })); entry.resume(); } catch (ex: any) { entry.destroy(ex); } }); entry.on('end', () => { writers.map(writer => writer.end()); }); for (const linkName of linkNames) { delete links[linkName]; } break; } default: console.info(`Ignoring unsupported file type ${ entry.header.name } (${ entry.header.type })`); } await stream.promises.finished(entry.resume() as any); } // Handle symlinks that were not found for (const [linkName, linkTarget] of Object.entries(links)) { console.warn(`Skipping missing link ${ linkName } -> ${ linkTarget }`); } } async getTags(imageName: string, options?: ContainerBasicOptions) { let results = new Set(); try { results = new Set(await dockerRegistry.getTags(imageName)); } catch (ex) { // We may fail here if the image doesn't exist / has an invalid host. console.debugE(`Could not get tags from registry for ${ imageName }, ignoring:`, ex); } try { const desired = parseImageReference(imageName); const { stdout } = await this.runClient( ['image', 'list', '--format={{ .Repository }}:{{ .Tag }}'], 'pipe', options); for (const imageRef of stdout.split(/\s+/).filter(v => v)) { const info = parseImageReference(imageRef); if (info?.tag && info.equalName(desired)) { results.add(info.tag); } } } catch (ex) { // Failure to list images is acceptable. console.debugE(`Could not get tags of existing images for ${ imageName }, ignoring:`, ex); } return results; } async run(imageID: string, options?: ContainerRunOptions): Promise { const args = ['container', 'run', '--detach']; args.push('--restart', options?.restart === 'always' ? 'always' : 'no'); if (options?.name) { args.push('--name', options.name); } args.push(imageID); try { const { stdout, stderr } = await this.runClient(args, 'pipe'); console.debug(stderr.trim()); return stdout.trim(); } catch (ex: any) { if (Object.prototype.hasOwnProperty.call(ex, ErrorCommand)) { const match = /container name "[^"]*" is already in use by container "(?[0-9a-f]+)"./.exec(ex.stderr ?? ''); const result = match?.groups?.['id']; if (result) { return result; } } throw ex; } } async stop(container: string, options?: ContainerStopOptions): Promise { if (options?.delete && options.force) { const { stderr } = await this.runClient(['container', 'rm', '--force', container], 'pipe'); if (!/Error: No such container: \S+/.test(stderr)) { console.debug(stderr.trim()); } return; } await this.runClient(['container', 'stop', container]); if (options?.delete) { await this.runClient(['container', 'rm', container]); } } async composeUp(options: ContainerComposeOptions): Promise { const args = ['--project-directory', options.composeDir]; if (options.name) { args.push('--project-name', options.name); } args.push('up', '--quiet-pull', '--wait', '--remove-orphans'); await this.runClient(args, console, { ...options, executable: 'docker-compose' }); console.debug('ran docker compose up'); } async composeDown(options: ContainerComposeOptions): Promise { const args = [ options.name ? ['--project-name', options.name] : [], ['--project-directory', options.composeDir, 'down'], ].flat(); await this.runClient(args, console, { ...options, executable: 'docker-compose' }); console.debug('ran docker compose down'); } composeExec(options: ContainerComposeExecOptions): Promise { const args = [ options.name ? ['--project-name', options.name] : [], ['--project-directory', options.composeDir, 'exec'], options.user ? ['--user', options.user] : [], options.workdir ? ['--workdir', options.workdir] : [], [options.service, ...options.command], ].flat(); return Promise.resolve(this.runClient(args, 'stream', { ...options, executable: 'docker-compose' })); } async composePort(options: ContainerComposePortOptions): Promise { const args = [ options.name ? ['--project-name', options.name] : [], ['--project-directory', options.composeDir, 'port'], options.protocol ? ['--protocol', options.protocol] : [], [options.service, options.port.toString()], ].flat(); const { stdout } = await this.runClient(args, 'pipe', { ...options, executable: 'docker-compose' }); return stdout.trim(); } runClient(args: string[], stdio?: 'ignore', options?: runClientOptions): Promise>; runClient(args: string[], stdio: Log, options?: runClientOptions): Promise>; runClient(args: string[], stdio: 'pipe', options?: runClientOptions): Promise<{ stdout: string; stderr: string; }>; runClient(args: string[], stdio: 'stream', options?: runClientOptions): ReadableProcess; runClient(args: string[], stdio: 'interactive', options?: runClientOptions): WritableReadableProcess; runClient(args: string[], stdio?: 'ignore' | 'pipe' | 'stream' | 'interactive' | Log, options?: runClientOptions) { // Always add the `bin` directory, as docker CLI plugins may need them too. const dirsToAdd = [path.join(paths.resources, process.platform, 'bin')]; const executableName = options?.executable ?? this.executable; const isCLIPlugin = /^docker-(?!credential-)/.test(executableName); const binType = isCLIPlugin ? 'docker-cli-plugins' : 'bin'; const executableDir = path.join(paths.resources, process.platform, binType); const executable = path.resolve(executableDir, executableName); if (isCLIPlugin) { dirsToAdd.push(executableDir); } const opts = _.merge({ env: _.merge({}, process.env) }, options ?? {}, { env: { DOCKER_HOST: this.endpoint, PATH: `${ process.env.PATH }${ path.delimiter }${ dirsToAdd.join(path.delimiter) }`, }, }); // Due to TypeScript reasons, we have to make each branch separately. switch (stdio) { case 'ignore': case undefined: return spawnFile(executable, args, { ...opts, stdio: 'ignore' }); case 'stream': return spawn(executable, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] }); case 'interactive': return spawn(executable, args, { ...opts, stdio: 'pipe' }); case 'pipe': return spawnFile(executable, args, { ...opts, stdio: 'pipe' }); } return spawnFile(executable, args, { ...opts, stdio }); } } ================================================ FILE: pkg/rancher-desktop/backend/containerClient/nerdctlClient.ts ================================================ import fs from 'fs'; import os from 'os'; import path from 'path'; import stream from 'stream'; import util from 'util'; import _ from 'lodash'; import tar from 'tar-stream'; import { ContainerComposeExecOptions, ReadableProcess, WritableReadableProcess, ContainerComposeOptions, ContainerEngineClient, ContainerRunOptions, ContainerStopOptions, ContainerRunClientOptions, ContainerComposePortOptions, ContainerBasicOptions, } from './types'; import { execOptions, VMExecutor } from '@pkg/backend/backend'; import dockerRegistry from '@pkg/backend/containerClient/registry'; import { spawn, spawnFile } from '@pkg/utils/childProcess'; import { parseImageReference } from '@pkg/utils/dockerUtils'; import Logging, { Log } from '@pkg/utils/logging'; import { executable } from '@pkg/utils/resources'; import { defined } from '@pkg/utils/typeUtils'; const console = Logging.nerdctl; /** * NerdctlClient manages nerdctl/containerd. */ export class NerdctlClient implements ContainerEngineClient { constructor(vm: VMExecutor) { this.vm = vm; } /** The VM backing Rancher Desktop */ readonly vm: VMExecutor; readonly executable = executable('nerdctl'); /** * Run nerdctl with the given arguments, returning the standard output. */ protected async nerdctl(...args: string[]): Promise; protected async nerdctl(options: { env?: Record }, ...args: string[]): Promise; protected async nerdctl(optionOrArg: any, ...args: string[]): Promise { const finalArgs = args.concat(); const options: { env?: Record } = {}; if (typeof optionOrArg === 'string') { finalArgs.unshift(optionOrArg); } else { _.merge(options, { env: { ...process.env, ...optionOrArg.env } }); } const { stdout } = await spawnFile( executable('nerdctl'), finalArgs, { stdio: ['ignore', 'pipe', console], ...options }, ); return stdout; } /** * Run a list of cleanup functions in reverse. */ protected async runCleanups(cleanups: (() => Promise)[]) { for (const cleanup of cleanups.reverse()) { try { await cleanup(); } catch (e) { console.error('Failed to run cleanup:', e); } } } /** * Like running this.vm.execCommand, but retries the command if no output * is produced. Is a workaround for a strange behavior of this.vm.execCommand: * sometimes nothing is returned from stdout, as though it did not run at * all. See https://github.com/rancher-sandbox/rancher-desktop/issues/4473 * for more info. */ protected async execCommandWithRetries(options: execOptions & { capture: true }, ...command: string[]): Promise { const maxRetries = 10; let result = ''; for (let i = 0; i < maxRetries && !result; i++) { result = await this.vm.execCommand({ ...options, capture: true }, ...command); } return result; } /** * Mount the given image inside the VM. * @param imageID The ID of the image to mount. * @returns The path that the image has been mounted on, plus an array of * cleanup functions that must be called in reverse order when done. * @note Due to https://github.com/containerd/nerdctl/issues/1058 we can't * just do `nerdctl create` + `nerdctl cp`. Instead, we need to make mounts * manually. */ protected async mountImage(imageID: string, namespace?: string): Promise<[string, (() => Promise)[]]> { const cleanups: (() => Promise)[] = []; try { const namespaceArgs = namespace === undefined ? [] : ['--namespace', namespace]; const container = (await this.execCommandWithRetries({ capture: true }, '/usr/local/bin/nerdctl', ...namespaceArgs, 'create', '--entrypoint=/', imageID)).trim(); cleanups.push(() => this.vm.execCommand( '/usr/local/bin/nerdctl', ...namespaceArgs, 'rm', '--force', '--volumes', container)); const workdir = (await this.execCommandWithRetries({ capture: true }, '/bin/mktemp', '-d', '-t', 'rd-nerdctl-cp-XXXXXX')).trim(); cleanups.push(() => this.vm.execCommand('/bin/rm', '-rf', workdir)); const command = await this.execCommandWithRetries({ capture: true, root: true }, '/usr/bin/ctr', ...namespaceArgs, '--address=/run/k3s/containerd/containerd.sock', 'snapshot', 'mounts', workdir, container); await this.vm.execCommand({ root: true }, ...command.trim().split(' ')); cleanups.push(async() => { try { await this.vm.execCommand({ root: true }, '/bin/umount', workdir); } catch (ex) { // Unmount might fail due to being busy; just detach and let it go // away by itself later. await this.vm.execCommand({ root: true }, '/bin/umount', '-l', workdir); } }); return [workdir, cleanups]; } catch (ex) { await this.runCleanups(cleanups); throw ex; } } async waitForReady(): Promise { // We need to check two things: containerd, and buildkitd. const commandsToCheck = [ ['/usr/local/bin/nerdctl', 'system', 'info'], ['/usr/local/bin/buildctl', 'debug', 'info'], ]; for (const cmd of commandsToCheck) { while (true) { try { await this.vm.execCommand({ expectFailure: true, root: true }, ...cmd); break; } catch (ex) { // Ignore the error, try again await util.promisify(setTimeout)(1_000); } } } } readFile(imageID: string, filePath: string): Promise; readFile(imageID: string, filePath: string, options: { encoding?: BufferEncoding, namespace?: string }): Promise; async readFile(imageID: string, filePath: string, options?: { encoding?: BufferEncoding, namespace?: string }): Promise { const encoding = options?.encoding ?? 'utf-8'; const [workdir, cleanups] = await this.mountImage(imageID, options?.namespace); try { // The await here is needed to ensure we read the result before running // any cleanups return await this.vm.readFile(path.posix.join(workdir, filePath), { encoding }); } finally { await this.runCleanups(cleanups); } } copyFile(imageID: string, sourcePath: string, destinationDir: string): Promise; copyFile(imageID: string, sourcePath: string, destinationDir: string, options: { namespace?: string }): Promise; async copyFile(imageID: string, sourcePath: string, destinationDir: string, options?: { namespace?: string }): Promise { const [imageDir, cleanups] = await this.mountImage(imageID, options?.namespace); try { // Archive the file(s) into the VM const workDir = (await this.execCommandWithRetries({ capture: true }, '/bin/mktemp', '-d', '-t', 'rd-nerdctl-cp-XXXXXX')).trim(); const archive = path.posix.join(workDir, 'archive.tgz'); const fileList = path.posix.join(workDir, 'files.txt'); cleanups.push(() => this.vm.execCommand('/bin/rm', '-rf', workDir)); let sourceName: string, sourceDir: string; if (sourcePath.endsWith('/')) { sourceName = '.'; sourceDir = path.posix.join(imageDir, sourcePath); } else { sourceName = path.posix.basename(sourcePath); sourceDir = path.posix.join(imageDir, path.posix.dirname(sourcePath)); } // Compute the list of all files to archive, but only including things // that (after resolving symlinks) point into the mount. // This means that absolute links to /proc etc. are skipped. await this.vm.execCommand({ root: true, cwd: sourceDir }, '/usr/bin/find', '-L', sourceName, '-xdev', '-type', 'f', // After resolving symlinks, the target is a regular file '-exec', '/bin/sh', '-c', `readlink -f {} | grep -q '${ imageDir }'`, ';', '-exec', '/bin/sh', '-c', `echo '{}' >> ${ fileList }`, ';'); const args = [ '--create', '--gzip', '--file', archive, '--directory', sourceDir, '--dereference', '--one-file-system', '--sparse', '--files-from', fileList, ].filter(defined); await this.vm.execCommand({ root: true }, '/bin/tar', ...args); // Copy the archive to the host const hostWorkDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-nerdctl-copy-')); cleanups.push(() => fs.promises.rm(hostWorkDir, { recursive: true, maxRetries: 3 })); const hostArchive = path.join(hostWorkDir, 'copy-file.tgz'); await this.vm.copyFileOut(archive, hostArchive); // Extract the archive into the destination. // Note that on Windows, we need to use the system-provided tar to handle Windows paths. const tar = process.platform === 'win32' ? path.join(process.env.SystemRoot ?? `C:\\Windows`, 'system32', 'tar.exe') : '/usr/bin/tar'; const extractArgs = ['xzf', hostArchive, '-C', destinationDir]; await fs.promises.mkdir(path.normalize(destinationDir), { recursive: true }); await spawnFile(tar, extractArgs, { stdio: console }); } finally { await this.runCleanups(cleanups); } } async getTags(imageName: string, options?: ContainerBasicOptions) { let results = new Set(); try { results = new Set(await dockerRegistry.getTags(imageName)); } catch (ex) { // We may fail here if the image doesn't exist / has an invalid host. console.debugE(`Could not get tags from registry for ${ imageName }, ignoring:`, ex); } try { const desired = parseImageReference(imageName); const { stdout } = await this.runClient( ['image', 'list', '--format={{ .Name }}'], 'pipe', options); for (const imageRef of stdout.split(/\s+/).filter(v => v)) { const info = parseImageReference(imageRef); if (info?.tag && info.equalName(desired)) { results.add(info.tag); } } } catch (ex) { // Failure to list images is acceptable. console.debugE(`Could not get tags of existing images for ${ imageName }, ignoring:`, ex); } return results; } async run(imageID: string, options?: ContainerRunOptions): Promise { const args = ['container', 'run', '--detach']; args.push('--restart', options?.restart === 'always' ? 'always' : 'no'); if (options?.name) { args.push('--name', options.name); } if (options?.namespace) { args.unshift('--namespace', options.namespace); } args.push(imageID); return (await this.nerdctl(...args)).trim(); } async stop(container: string, options?: ContainerStopOptions): Promise { function addNS(...args: string[]) { if (options?.namespace) { return [`--namespace=${ options.namespace }`, ...args]; } return args; } if (options?.delete && options.force) { await this.nerdctl(...addNS('container', 'rm', '--force', container)); return; } await this.nerdctl(...addNS('container', 'stop', container)); if (options?.delete) { await this.nerdctl(...addNS('container', 'rm', container)); } } /** * Copy the given host directory into a temporary directory in the VM * @param hostPath The path on the host to a directory. * @returns The temporary path in the VM holding the results. */ protected async copyDirectoryIn(hostPath: string): Promise { const cleanups: (() => Promise)[] = []; let succeeded = false; try { const workDir = (await this.vm.execCommand({ capture: true }, '/bin/mktemp', '--directory', '--tmpdir', 'rd-nerdctl-copy-in-XXXXXX')).trim(); cleanups.push(() => this.vm.execCommand('/bin/rm', '-rf', workDir)); const resultDir = (await this.vm.execCommand({ capture: true }, '/bin/mktemp', '--directory', '--tmpdir', 'rd-nerdctl-copy-in-XXXXXX')).trim(); cleanups.push(async() => { if (!succeeded) { await this.vm.execCommand('/bin/rm', '-rf', workDir); } }); const archiveName = 'nerdctl-copy-in.tar'; const hostDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-nerdctl-copy-in-')); cleanups.push(() => fs.promises.rm(hostDir, { recursive: true, maxRetries: 3 })); const tarStream = fs.createWriteStream(path.join(hostDir, archiveName)); const archive = tar.pack(); const archiveFinished = util.promisify(stream.finished)(archive as any); const newEntry = util.promisify(archive.entry.bind(archive)); const baseHeader: Partial = { mode: 0o755, uid: 0, uname: 'root', gname: 'wheel', type: 'directory', }; const walk = async(dir: string) => { const fullPath = path.normalize(path.join(hostPath, dir)); for (const basename of await fs.promises.readdir(fullPath)) { const name = path.normalize(path.join(dir, basename)); const info = await fs.promises.lstat(path.join(fullPath, basename)); if (info.isDirectory()) { await newEntry({ ...baseHeader, name }); await walk(path.join(dir, basename)); } else if (info.isFile()) { const readStream = fs.createReadStream(path.join(fullPath, basename)); const entry = archive.entry({ ...baseHeader, ..._.pick(info, 'mode', 'mtime', 'size'), type: 'file', name, }); const entryFinished = util.promisify(stream.finished)(entry); readStream.pipe(entry); await entryFinished; } else if (info.isSymbolicLink()) { await newEntry({ ...baseHeader, ..._.pick(info, 'mode', 'mtime'), name, type: 'symlink', linkname: await fs.promises.readlink(path.join(fullPath, basename)), }); } } }; archive.pipe(tarStream); await walk('.'); archive.finalize(); await archiveFinished; await this.vm.copyFileIn(path.join(hostDir, archiveName), path.posix.join(workDir, archiveName)); await this.vm.execCommand('/bin/tar', 'xf', path.posix.join(workDir, archiveName), '-C', resultDir); succeeded = true; return resultDir; } finally { await this.runCleanups(cleanups); } } /** * Sets up the environment for compose. * @returns [projectDir] The compose project directory to use. * @returns [envFile] The environment file to use. * @returns [cleanups] Any cleanups we need to run after */ protected async composePrep(options: ContainerComposeOptions): Promise<{ projectDir: string, envFile: string, cleanups: (() => Promise)[], }> { const cleanups: (() => Promise)[] = []; const envData = Object.entries(options.env ?? {}) .map(([k, v]) => `${ k }='${ v.replaceAll("'", "\\'") }'\n`) .join(''); try { if (this.vm.backend === 'wsl') { // For WSL, we don't need to copy anything; nerdctl-stub will translate // the paths correctly. const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-compose-')); const envFile = path.join(workDir, 'env.txt'); cleanups.push(() => fs.promises.rm(workDir, { recursive: true, maxRetries: 3 })); await fs.promises.writeFile(envFile, envData); return { projectDir: options.composeDir, envFile, cleanups, }; } const projectDir = await this.copyDirectoryIn(options.composeDir); cleanups.push(() => this.vm.execCommand('/bin/rm', '-rf', projectDir)); const envFile = (await (this.vm.execCommand({ capture: true }, '/bin/mktemp', '--tmpdir', 'rd-nerdctl-compose-XXXXXX'))).trim(); cleanups.push(() => this.vm.execCommand('/bin/rm', '-f', envFile)); await this.vm.writeFile(envFile, envData); return { projectDir, envFile, cleanups, }; } catch (ex) { await this.runCleanups(cleanups); throw ex; } } async composeUp(options: ContainerComposeOptions): Promise { const { projectDir, envFile, cleanups } = await this.composePrep(options); try { const args = ['compose', '--project-directory', projectDir]; if (options.name) { args.push('--project-name', options.name); } if (options.namespace) { args.unshift('--namespace', options.namespace); } if (options.env) { args.push('--env-file', envFile); } // nerdctl doesn't support --wait, so make do with --detach. args.push('up', '--quiet-pull', '--detach'); const result = await this.nerdctl({ env: options.env ?? {} }, ...args); console.log('ran nerdctl compose up', result); } finally { await this.runCleanups(cleanups); } } async composeDown(options: ContainerComposeOptions): Promise { const { projectDir, envFile, cleanups } = await this.composePrep(options); try { const args = [ options.namespace ? ['--namespace', options.namespace] : [], ['compose'], options.name ? ['--project-name', options.name] : [], ['--project-directory', projectDir, 'down'], options.env ? ['--env-file', envFile] : [], ].flat(); const result = await this.nerdctl(...args); console.debug('ran nerdctl compose down:', result); } finally { await this.runCleanups(cleanups); } } async composeExec(options: ContainerComposeExecOptions): Promise { const { projectDir, envFile, cleanups } = await this.composePrep(options); try { const args = [ options.namespace ? ['--namespace', options.namespace] : [], ['compose'], options.name ? ['--project-name', options.name] : [], ['--project-directory', projectDir], options.env ? ['--env-file', envFile] : [], ['exec', '--tty=false'], options.user ? ['--user', options.user] : [], options.workdir ? ['--workdir', options.workdir] : [], [options.service, ...options.command], ].flat(); const result = spawn(executable('nerdctl'), args, { stdio: ['ignore', 'pipe', 'pipe'] }); const delayedCleanups = cleanups.concat(); // Delay running cleanups until the process has finished to avoid removing // files that may still be necessary. result.on('exit', () => this.runCleanups(delayedCleanups)); result.on('error', () => this.runCleanups(delayedCleanups)); cleanups.splice(0, cleanups.length); return result; } finally { await this.runCleanups(cleanups); } } async composePort(options: ContainerComposePortOptions): Promise { const { projectDir, envFile, cleanups } = await this.composePrep(options); try { const args = [ options.namespace ? ['--namespace', options.namespace] : [], ['compose'], options.name ? ['--project-name', options.name] : [], ['--project-directory', projectDir], options.env ? ['--env-file', envFile] : [], ['port'], options.protocol ? ['--protocol', options.protocol] : [], [options.service, options.port.toString(10)], ].flat(); return (await this.nerdctl(...args)).trim(); } finally { await this.runCleanups(cleanups); } } runClient(args: string[], stdio?: 'ignore', options?: ContainerRunClientOptions): Promise>; runClient(args: string[], stdio: Log, options?: ContainerRunClientOptions): Promise>; runClient(args: string[], stdio: 'pipe', options?: ContainerRunClientOptions): Promise<{ stdout: string; stderr: string; }>; runClient(args: string[], stdio: 'stream', options?: ContainerRunClientOptions): ReadableProcess; runClient(args: string[], stdio: 'interactive', options?: ContainerRunClientOptions): WritableReadableProcess; runClient(args: string[], stdio?: 'ignore' | 'pipe' | 'stream' | 'interactive' | Log, options?: ContainerRunClientOptions) { const opts = _.merge({ env: process.env }, options); if (opts.namespace) { args = ['--namespace', opts.namespace].concat(args); } // Due to TypeScript reasons, we have to make each branch separately. switch (stdio) { case 'ignore': case undefined: return spawnFile(this.executable, args, { ...opts, stdio: 'ignore' }); case 'stream': return spawn(this.executable, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] }); case 'interactive': return spawn(this.executable, args, { ...opts, stdio: 'pipe' }); case 'pipe': return spawnFile(this.executable, args, { ...opts, stdio: 'pipe' }); } return spawnFile(this.executable, args, { ...opts, stdio }); } } ================================================ FILE: pkg/rancher-desktop/backend/containerClient/registry.ts ================================================ import { net } from 'electron'; import registryAuth from '@pkg/backend/containerClient/auth'; import { parseImageReference } from '@pkg/utils/dockerUtils'; /** * Registry interaction, with both Docker Hub and Docker Registry V2 APIs. */ class DockerRegistry { /** * Fetch some API endpoint from the registry * @param endpoint The API endpoint, including the registry host. */ async get(endpoint: URL): ReturnType { const headers = await this.authenticate(endpoint); return await net.fetch(endpoint.toString(), { headers }); } /** * List all tags for the given image name. * @param name An image name, including registry as needed. */ async getTags(name: string): Promise { const info = parseImageReference(name); const tags: string[] = []; if (!info) { throw new Error(`Invalid image name: "${ name }"`); } let endpoint = new URL(`/v2/${ info.name }/tags/list?n=65536`, info.registry); let hasMore = true; while (hasMore) { const resp = await this.get(endpoint); if (!resp.ok) { throw new Error(`Failed to fetch ${ endpoint }: ${ resp.status } ${ resp.statusText }`); } const result: { name: string, tags: string[] } = await resp.json(); if (result.name !== info.name) { throw new Error(`Invalid tags: incorrect response name ${ result.name } from ${ endpoint }`); } tags.push(...result.tags); hasMore = false; for (const link of (resp.headers.get('Link') ?? '').split(', ')) { const fields = link.split(/;\s*/); if (!fields.some(field => /^rel=("?)next\1$/i.test(field))) { continue; } // The `Link` header defined in RFC 8288 always has angle brackets // around the (possibly relative) URL: // https://www.rfc-editor.org/rfc/rfc8288#section-3 endpoint = new URL(fields[0].replace(/^<(.+)>$/, '$1'), endpoint); hasMore = true; } } return tags; } protected authenticate(endpoint: URL): Promise { return registryAuth.authenticate(endpoint); } } const registry = new DockerRegistry(); export default registry; ================================================ FILE: pkg/rancher-desktop/backend/containerClient/types.ts ================================================ import type { Log } from '@pkg/utils/logging'; import type { ChildProcessByStdio, SpawnOptions } from 'child_process'; import type { Readable, Writable } from 'stream'; export interface ContainerBasicOptions { /** * Namespace the container should be created in. * @note Silently ignored when using moby. */ namespace?: string; } /** * ContainerRunOptions are the options that can be passed to * ContainerEngineClient.run(). All fields are optional. */ export type ContainerRunOptions = ContainerBasicOptions & { /** The name of the container. */ name?: string; /** Container restart policy, defaults to "no". */ restart?: 'always' | 'no'; }; /** * ContainerStopOptions are the options that can be passed to * ContainerEngineClient.stop(). All fields are optional. */ export type ContainerStopOptions = ContainerBasicOptions & { /** Force stop the container (killing it uncleanly). */ force?: true; /** Delete the container after stopping. */ delete?: true; }; /** * ContainerComposeOptions are options that can be passed to * ContainerEngineClient.composeUp() and .composeDown(). All fields are * optional. */ export type ContainerComposeOptions = ContainerBasicOptions & { /** The directory holding the compose files. */ composeDir: string; /** The name of the project */ name?: string; /** Environment variables to set on build */ env?: Record; }; export type ContainerComposeExecOptions = ContainerComposeOptions & { /** The service to exec in. */ service: string; /** The command (and arguments) to execute. */ command: string[]; /** Run the command as the given (in-container) user. */ user?: string, /** Run the command in the given (in-container) directory */ workdir?: string; }; /** ReadableProcess describes a process that is capturing output */ export type ReadableProcess = ChildProcessByStdio; /** WritableReadableProcess describes a process with stdin, stdout, and stderr all piped */ export type WritableReadableProcess = ChildProcessByStdio; export type ContainerComposePortOptions = ContainerComposeOptions & { /** The service to find the port for */ service: string; /** The private port to map */ port: number; /** The protocol to use */ protocol: 'tcp' | 'udp'; }; /** * ContainerRunClientOptions describes arguments to * ContainerEngineClient.runClient() */ export type ContainerRunClientOptions = SpawnOptions & { namespace?: string }; /** * ContainerEngineClient is used to run commands on the container engine. */ export interface ContainerEngineClient { /** * Block until the container engine is ready. */ waitForReady(): Promise; /** * Read the file from the given container image. * @param imageID The ID of the image to read. * @param filePath The file to read, relative to the root of the container. * @param [options.encoding='utf-8'] The encoding to read. * @param [options.namespace] Namespace the image is in, if supported. */ readFile(imageID: string, filePath: string): Promise; readFile(imageID: string, filePath: string, options: { encoding?: BufferEncoding, namespace?: string }): Promise; /** * Copy the given file to disk. * @param imageID The ID of the image to copy files from. * @param sourcePath The source path (inside the image) to copy from. * This may be the path to a file or a directory. If this is a directory, it * must end with a slash. * @param destinationDir The destination path (on the host) to copy to. * If sourcePath is a directory, then its contents will be place here without * an extra directory. Otherwise, this is the parent directory, and the * named file will be created within this directory with the same base name as * in the VM. * @param [options.namespace] Namespace the image is in, if supported. * @note Symbolic links are always resolved, as some hosts might not support * them. */ copyFile(imageID: string, sourcePath: string, destinationDir: string): Promise; copyFile(imageID: string, sourcePath: string, destinationDir: string, options: { namespace?: string }): Promise; /** * Get all tags available for the given image name. * @param imageName the image name, possibly including the registry, but * excluding the tag. */ getTags(imageName: string, options?: ContainerBasicOptions): Promise>; /** * Start a container. * @param imageID The ID of the image to use. * @note The container will be run detached (no IO). * @returns The container ID. */ run(imageID: string, options?: ContainerRunOptions): Promise; /** * Stop the given container, if it exists and is running. */ stop(container: string, options?: ContainerStopOptions): Promise; /** * Start containers via `docker compose` / `nerdctl compose`. */ composeUp(options: ContainerComposeOptions): Promise; /** * Stop containers via `docker compose` / `nerdctl compose`. */ composeDown(options?: ContainerComposeOptions): Promise; /** * Spawn a process using `docker compose exec` / `nerdctl ...`, returning a * raw process that has stdout and stderr set to pipe (but nothing for stdin). */ composeExec(options: ContainerComposeExecOptions): Promise; /** * Get port information for a compose service. * @returns The port information, looking like `0.0.0.0:12345`. */ composePort(options: ContainerComposePortOptions): Promise; /** * Run the client directly, using the given arguments. The 'stdio' argument * determines the return value. */ runClient(args: string[], stdio?: 'ignore', options?: ContainerRunClientOptions): Promise>; runClient(args: string[], stdio: Log, options?: ContainerRunClientOptions): Promise>; runClient(args: string[], stdio: 'pipe', options?: ContainerRunClientOptions): Promise<{ stdout: string, stderr: string }>; runClient(args: string[], stdio: 'stream', options?: ContainerRunClientOptions): ReadableProcess; runClient(args: string[], stdio: 'interactive', options?: ContainerRunClientOptions): WritableReadableProcess; } ================================================ FILE: pkg/rancher-desktop/backend/factory.ts ================================================ import os from 'os'; import { Architecture, VMBackend } from './backend'; import LimaKubernetesBackend from './kube/lima'; import WSLKubernetesBackend from './kube/wsl'; import LimaBackend from './lima'; import MockBackend from './mock'; import WSLBackend from './wsl'; import { LimaKubernetesBackendMock, WSLKubernetesBackendMock } from '@pkg/backend/mock_screenshots'; export default function factory(arch: Architecture): VMBackend { const platform = os.platform(); if (process.env.RD_MOCK_BACKEND === '1') { return new MockBackend(); } switch (platform) { case 'linux': case 'darwin': return new LimaBackend(arch, (backend: LimaBackend) => { if (process.env.RD_MOCK_FOR_SCREENSHOTS) { return new LimaKubernetesBackendMock(arch, backend); } else { return new LimaKubernetesBackend(arch, backend); } }); case 'win32': return new WSLBackend((backend: WSLBackend) => { if (process.env.RD_MOCK_FOR_SCREENSHOTS) { return new WSLKubernetesBackendMock(backend); } else { return new WSLKubernetesBackend(backend); } }); default: throw new Error(`OS "${ platform }" is not supported.`); } } ================================================ FILE: pkg/rancher-desktop/backend/images/imageFactory.ts ================================================ import { VMBackend } from '@pkg/backend/backend'; import { ImageProcessor } from '@pkg/backend/images/imageProcessor'; import MobyImageProcessor from '@pkg/backend/images/mobyImageProcessor'; import NerdctlImageProcessor from '@pkg/backend/images/nerdctlImageProcessor'; import { ContainerEngine } from '@pkg/config/settings'; const cachedImageProcessors: Partial> = { }; /** * Return the appropriate ImageProcessor singleton for the specified ContainerEngine. */ export function getImageProcessor(engineName: ContainerEngine, executor: VMBackend): ImageProcessor { if (!(engineName in cachedImageProcessors)) { switch (engineName) { case ContainerEngine.MOBY: cachedImageProcessors[engineName] = new MobyImageProcessor(executor); break; case ContainerEngine.CONTAINERD: cachedImageProcessors[engineName] = new NerdctlImageProcessor(executor); break; default: throw new Error(`No image processor called ${ engineName }`); } } return cachedImageProcessors[engineName]!; } ================================================ FILE: pkg/rancher-desktop/backend/images/imageProcessor.ts ================================================ import { Buffer } from 'buffer'; import { EventEmitter } from 'events'; import timers from 'timers'; import { VMBackend, VMExecutor } from '@pkg/backend/backend'; import mainEvents from '@pkg/main/mainEvents'; import { ChildProcess, ErrorCommand } from '@pkg/utils/childProcess'; import Logging from '@pkg/utils/logging'; import * as window from '@pkg/window'; const REFRESH_INTERVAL = 5 * 1000; const console = Logging.images; /** * The fields that cover the results of a finished process. * Not all fields are set for every process. */ export interface childResultType { stdout: string; stderr: string; code: number; signal?: string; } /** * The fields for display in the images table */ export interface ImageType { imageName: string; tag: string; imageID: string; size: string; digest: string; } /** * Options for `processChildOutput`. */ interface ProcessChildOutputOptions { /** The name of the executable; defaults to `processorName`. */ commandName?: string; /** The sub-command being executed; typically the first argument. */ subcommandName: string; /** What notifications to send. */ notifications?: { /** Send stdout as it comes in via `image-process-output`. */ stdout?: boolean; /** Send stderr as it comes in via `image-process-output`. */ stderr?: boolean; /** Send stdout after the command succeeds to the window via `ok:images-process-output`. */ ok?: boolean; } } /** * ImageProcessors take requests, from the UI or caused by state transitions * (such as a K8s engine hitting the STARTED state), and invokes the appropriate * client to run commands and send output to the UI. * * Each concrete ImageProcessor is a singleton, with a 1:1 correspondence between * the current container engine the user has selected, and its ImageProcessor. * * Currently, some events are handled directly by the concrete ImageProcessor subclasses, * and some are handled by the ImageEventHandler singleton, which calls methods on * the current ImageProcessor. Because these events are sent to all imageProcessors, but * only one should actually act on them, we use the concept of the `active` processor * to determine which processor acts on its events. * * When all the event-handlers have been moved into the ImageEventHandler the concept of * an active ImageProcessor can be dropped. */ export abstract class ImageProcessor extends EventEmitter { protected backend: VMBackend; // Sometimes the `images` subcommand repeatedly fires the same error message. // Instead of logging it every time, keep track of the current error and give a count instead. private lastErrorMessage = ''; private sameErrorMessageCount = 0; protected showedStderr = false; private refreshInterval: ReturnType | null = null; protected images: ImageType[] = []; protected _isReady = false; protected isK8sReady = false; private hasImageListeners = false; private isWatching = false; _refreshImages: () => Promise; protected currentNamespace = 'default'; // See https://github.com/rancher-sandbox/rancher-desktop/issues/977 // for a task to get rid of the concept of an active imageProcessor. // All the event handlers should be on the imageEventHandler, which knows // which imageProcessor is currently active, and it can direct events to that. protected active = false; protected constructor(backend: VMBackend) { super(); this.backend = backend; this._refreshImages = this.refreshImages.bind(this); this.on('newListener', (event: string | symbol) => { if (!this.active) { return; } if (event === 'images-changed' && !this.hasImageListeners) { this.hasImageListeners = true; this.updateWatchStatus(); } }); this.on('removeListener', (event: string | symbol) => { if (!this.active) { return; } if (event === 'images-changed' && this.hasImageListeners) { this.hasImageListeners = this.listeners('images-changed').length > 0; this.updateWatchStatus(); } }); this.on('readiness-changed', (state: boolean) => { if (!this.active) { return; } window.send('images-check-state', state); }); this.on('images-process-output', (data: string, isStderr: boolean) => { if (!this.active) { return; } window.send('images-process-output', data, isStderr); }); mainEvents.on('settings-update', (cfg) => { if (!this.active) { return; } if (this.namespace !== cfg.images.namespace) { this.namespace = cfg.images.namespace; this.refreshImages() .catch((err: Error) => { console.log(`Error refreshing images:`, err); }); } }); } activate() { this.active = true; } deactivate() { this.active = false; } protected updateWatchStatus() { const shouldWatch = this.isK8sReady && this.hasImageListeners; if (this.isWatching === shouldWatch) { return; } if (this.refreshInterval) { timers.clearInterval(this.refreshInterval); } if (shouldWatch) { this.refreshInterval = timers.setInterval(this._refreshImages, REFRESH_INTERVAL); timers.setImmediate(this._refreshImages); } this.isWatching = shouldWatch; } /** * Are images ready for display in the UI? */ get isReady() { return this._isReady; } /** * Wrapper around the trivy command to scan the specified image. * @param taggedImageName The name of the image, e.g. `registry.opensuse.org/opensuse/leap:15.6`. * @param namespace The namespace to scan. */ abstract scanImage(taggedImageName: string, namespace: string): Promise; /** * Scan an image using trivy. * @param taggedImageName The image to scan, e.g. `registry.opensuse.org/opensuse/leap:15.6`. * @param env Extra environment variables to set, e.g. `CONTAINERD_NAMESPACE`. */ async runTrivyScan(taggedImageName: string, env?: Record) { const imageSrc = { docker: 'docker', nerdctl: 'containerd', }[this.processorName] ?? this.processorName; const args = ['trivy', '--quiet', 'image', '--image-src', imageSrc, '--format', 'json', taggedImageName]; if (env) { args.unshift('/usr/bin/env', ...Object.entries(env).map(([k, v]) => `${ k }=${ v }`)); } return await this.processChildOutput( this.backend.executor.spawn({ root: true }, ...args), { commandName: 'trivy', subcommandName: 'image', // Do not set stdout to avoid dumping JSON that nobody ever reads. notifications: { stderr: true, ok: true }, }, ); } /** * Returns the current list of cached images. */ listImages(): ImageType[] { return this.images; } isChildResultType(object: any): object is childResultType { return 'stderr' in object && 'stdout' in object && 'signal' in object && 'code' in object; } /** * Refreshes the current cache of processed images. */ async refreshImages() { try { const result:childResultType = await this.getImages(); if (result.stderr) { if (!this.showedStderr) { console.log(`${ this.processorName } images: ${ result.stderr } `); this.showedStderr = true; } } else { this.showedStderr = false; } this.images = this.parse(result.stdout); if (!this._isReady) { this._isReady = true; this.emit('readiness-changed', true); } this.emit('images-changed', this.images); } catch (err) { if (!this.showedStderr) { if (this.isChildResultType(err) && !err.stdout && !err.signal) { console.log(err.stderr); } else { console.log(err); } } this.showedStderr = true; if (this.isChildResultType(err) && this._isReady) { this._isReady = false; this.emit('readiness-changed', false); } } } protected parse(data: string): ImageType[] { const results = data .trimEnd() .split(/\r?\n/) .slice(1) .map((line) => { const [imageName, tag, digest, imageID, _created, _platform, size, _blobSize] = line.split(/\s+/); return { imageName, tag, imageID, size, digest, }; }); return results; } /** * Takes the `childProcess` returned by a command like `child_process.spawn` and processes the * output streams and exit code and signal. * * @param child The child process to monitor. * @param options Additional options. */ async processChildOutput(child: ChildProcess, options: ProcessChildOutputOptions): Promise { const { subcommandName } = options; const result = { stdout: '', stderr: '' }; const commandName = options.commandName ?? this.processorName; const command = `${ commandName } ${ subcommandName }`; const sendNotifications = options.notifications ?? { stdout: true, stderr: true, ok: true, }; return await new Promise((resolve, reject) => { child.stdout?.on('data', (data: Buffer) => { const dataString = data.toString(); if (sendNotifications.stdout) { this.emit('images-process-output', dataString, false); } result.stdout += dataString; }); child.stderr?.on('data', (data: Buffer) => { let dataString = data.toString(); if (commandName === 'nerdctl' && subcommandName === 'images') { /** * `nerdctl images` issues some dubious error messages * (see https://github.com/containerd/nerdctl/issues/353 , logged 2021-09-10) * Pull them out for now */ dataString = dataString .replace(/time=".+?"\s+level=.+?\s+msg="failed to compute image\(s\) size"\s*/g, '') .replace(/time=".+?"\s+level=.+?\s+msg="unparsable image name.*?sha256:[0-9a-fA-F]{64}.*?\\""\s*/g, ''); if (!dataString) { return; } } result.stderr += dataString; if (sendNotifications.stderr) { this.emit('images-process-output', dataString, true); } }); child.on('exit', (code, signal) => { if (result.stderr) { const timeLessMessage = result.stderr.replace(/\btime=".*?"/g, ''); if (this.lastErrorMessage !== timeLessMessage) { this.lastErrorMessage = timeLessMessage; this.sameErrorMessageCount = 1; console.log(`> ${ command }:\r\n${ result.stderr.replace(/(?!<\r)\n/g, '\r\n') }`); } else { const m = /(Error: .*)/.exec(this.lastErrorMessage); this.sameErrorMessageCount += 1; console.log(`${ command }: ${ m ? m[1] : 'same error message' } #${ this.sameErrorMessageCount }\r`); } } else if (commandName === 'trivy') { console.log(`> ${ command }: returned ${ result.stdout.length } bytes on stdout`); } else { console.log(`> ${ command }:\n${ result.stdout.replace(/(?!<\r)\n/g, '\r\n') }`); } if (code === 0) { if (sendNotifications.ok) { window.send('ok:images-process-output', result.stdout); } resolve({ ...result, code }); } else if (signal) { reject(Object.create(result, { code: { value: -1 }, signal: { value: signal }, [ErrorCommand]: { enumerable: false, value: child.spawnargs, }, })); } else { reject(Object.create(result, { code: { value: code }, [ErrorCommand]: { enumerable: false, value: child.spawnargs, }, })); } }); }); } /** * Called normally when the UI requests the current list of namespaces * for the current imageProcessor. * * containerd starts with two namespaces: "k8s.io" and "default". * There's no way to add other namespaces in the UI, * but they can easily be added from the command-line. * * See https://github.com/rancher-sandbox/rancher-desktop/issues/978 for being notified * without polling on changes in the namespaces. */ async relayNamespaces() { const namespaces = await this.getNamespaces(); const comparator = Intl.Collator(undefined, { sensitivity: 'base' }).compare; if (!namespaces.includes('default')) { namespaces.push('default'); } window.send('images-namespaces', namespaces.sort(comparator)); } get namespace() { return this.currentNamespace; } set namespace(value: string) { this.currentNamespace = value; } /* Subclass-specific method definitions here: */ protected abstract get processorName(): string; abstract getNamespaces(): Promise; abstract buildImage(dirPart: string, filePart: string, taggedImageName: string): Promise; abstract deleteImage(imageID: string): Promise; abstract deleteImages(imageIDs: string[]): Promise; abstract pullImage(taggedImageName: string): Promise; abstract pushImage(taggedImageName: string): Promise; abstract getImages(): Promise; } ================================================ FILE: pkg/rancher-desktop/backend/images/mobyImageProcessor.ts ================================================ import path from 'path'; import { VMBackend } from '@pkg/backend/backend'; import * as imageProcessor from '@pkg/backend/images/imageProcessor'; import * as K8s from '@pkg/backend/k8s'; import mainEvents from '@pkg/main/mainEvents'; import Logging from '@pkg/utils/logging'; import * as window from '@pkg/window'; const console = Logging.images; export default class MobyImageProcessor extends imageProcessor.ImageProcessor { constructor(backend: VMBackend) { super(backend); mainEvents.on('k8s-check-state', (mgr: VMBackend) => { if (!this.active) { return; } this.isK8sReady = mgr.state === K8s.State.STARTED || mgr.state === K8s.State.DISABLED; this.updateWatchStatus(); }); } protected get processorName() { return 'docker'; } protected async runImagesCommand(args: string[], sendNotifications = true): Promise { const subcommandName = args[0]; return this.processChildOutput( this.backend.containerEngineClient.runClient(args, 'stream'), { subcommandName, notifications: { stdout: sendNotifications, stderr: sendNotifications, ok: sendNotifications, }, }); } async buildImage(dirPart: string, filePart: string, taggedImageName: string): Promise { const args = ['build', '--file', path.join(dirPart, filePart), '--tag', taggedImageName, dirPart]; return await this.runImagesCommand(args); } async deleteImage(imageID: string): Promise { return await this.runImagesCommand(['rmi', imageID]); } async deleteImages(imageIDs: string[]): Promise { return await this.runImagesCommand(['rmi', ...imageIDs]); } async pullImage(taggedImageName: string): Promise { return await this.runImagesCommand(['pull', taggedImageName]); } async pushImage(taggedImageName: string): Promise { return await this.runImagesCommand(['push', taggedImageName]); } async getImages(): Promise { return await this.runImagesCommand( ['images', '--digests', '--format', '{{json .}}'], false); } scanImage(taggedImageName: string, namespace: string): Promise { return this.runTrivyScan(taggedImageName); } relayNamespaces(): Promise { window.send('images-namespaces', []); return Promise.resolve(); } getNamespaces(): Promise { throw new Error("docker doesn't support namespaces"); } /** * Sample output (line-oriented JSON output, as opposed to one JSON document): * * {"CreatedAt":"2021-10-05 22:04:12 +0000 UTC","CreatedSince":"20 hours ago","ID":"171689e43026","Repository":"","Tag":"","Size":"119.2 MiB"} * {"CreatedAt":"2021-10-05 22:04:20 +0000 UTC","CreatedSince":"20 hours ago","ID":"55fe4b211a51","Repository":"rancher/k3d","Tag":"v0.1.0-beta.7","Size":"46.2 MiB"} * ... */ parse(data: string): imageProcessor.ImageType[] { const images: imageProcessor.ImageType[] = []; const records = data .split(/\r?\n/) .filter(line => line.trim().length > 0) .map((line) => { try { return JSON.parse(line); } catch (err) { console.log(`Error json-parsing line [${ line }]:`, err); return null; } }) .filter(record => record); for (const record of records) { if (['', 'sha256'].includes(record.Repository)) { continue; } images.push({ imageName: record.Repository, tag: record.Tag, imageID: record.ID, size: record.Size, digest: record.Digest, }); } return images.sort(imageComparator); } } function imageComparator(a: imageProcessor.ImageType, b: imageProcessor.ImageType): number { return a.imageName.localeCompare(b.imageName) || a.tag.localeCompare(b.tag) || a.imageID.localeCompare(b.imageID); } ================================================ FILE: pkg/rancher-desktop/backend/images/nerdctlImageProcessor.ts ================================================ import path from 'path'; import { VMBackend } from '@pkg/backend/backend'; import * as imageProcessor from '@pkg/backend/images/imageProcessor'; import * as K8s from '@pkg/backend/k8s'; import mainEvents from '@pkg/main/mainEvents'; import * as childProcess from '@pkg/utils/childProcess'; import Logging from '@pkg/utils/logging'; import { executable } from '@pkg/utils/resources'; const console = Logging.images; export default class NerdctlImageProcessor extends imageProcessor.ImageProcessor { constructor(backend: VMBackend) { super(backend); mainEvents.on('k8s-check-state', (mgr: VMBackend) => { if (!this.active) { return; } this.isK8sReady = mgr.state === K8s.State.STARTED || mgr.state === K8s.State.DISABLED; this.updateWatchStatus(); }); } protected get processorName() { return 'nerdctl'; } protected async runImagesCommand(args: string[], sendNotifications = true): Promise { const subcommandName = args[0]; return await this.processChildOutput( this.backend.containerEngineClient.runClient(args, 'stream', { namespace: this.currentNamespace }), { subcommandName, notifications: { stdout: sendNotifications, stderr: sendNotifications, ok: sendNotifications, }, }); } async buildImage(dirPart: string, filePart: string, taggedImageName: string): Promise { const args = ['build', '--buildkit-host', 'unix:///run/buildkit/buildkitd.sock', '--file', path.join(dirPart, filePart), '--tag', taggedImageName, dirPart]; return await this.runImagesCommand(args); } async deleteImage(imageID: string): Promise { return await this.runImagesCommand(['rmi', imageID]); } async deleteImages(imageIDs: string[]): Promise { return await this.runImagesCommand(['rmi', ...imageIDs]); } async pullImage(taggedImageName: string): Promise { return await this.runImagesCommand(['pull', taggedImageName]); } async pushImage(taggedImageName: string): Promise { return await this.runImagesCommand(['push', taggedImageName]); } async getImages(): Promise { return await this.runImagesCommand( ['images', '--digests', '--format', '{{json .}}'], false); } scanImage(taggedImageName: string, namespace: string): Promise { return this.runTrivyScan( taggedImageName, { CONTAINERD_ADDRESS: '/run/k3s/containerd/containerd.sock', CONTAINERD_NAMESPACE: namespace, }, ); } async getNamespaces(): Promise { const { stdout, stderr } = await childProcess.spawnFile(executable('nerdctl'), ['namespace', 'list', '--quiet'], { stdio: ['inherit', 'pipe', 'pipe'] }); if (stderr) { console.log(`Error getting namespaces: ${ stderr }`, stderr); } return stdout.trim().split(/\r?\n/).map(line => line.trim()).sort(); } /** * Sample output (line-oriented JSON output, as opposed to one JSON document): * * {"CreatedAt":"2021-10-05 22:04:12 +0000 UTC","CreatedSince":"20 hours ago","ID":"171689e43026","Repository":"","Tag":"","Size":"119.2 MiB"} * {"CreatedAt":"2021-10-05 22:04:20 +0000 UTC","CreatedSince":"20 hours ago","ID":"55fe4b211a51","Repository":"rancher/k3d","Tag":"v0.1.0-beta.7","Size":"46.2 MiB"} * ... */ parse(data: string): imageProcessor.ImageType[] { const images: imageProcessor.ImageType[] = []; const records = data.split(/\r?\n/) .filter(line => line.trim().length > 0) .map((line) => { try { return JSON.parse(line); } catch (err) { console.log(`Error json-parsing line [${ line }]:`, err); return null; } }) .filter(record => record); for (const record of records) { if (['', 'sha256'].includes(record.Repository)) { continue; } images.push({ imageName: record.Repository, tag: record.Tag, imageID: record.ID, size: record.Size, digest: record.Digest, }); } return images.sort(imageComparator); } } function imageComparator(a: imageProcessor.ImageType, b: imageProcessor.ImageType): number { return a.imageName.localeCompare(b.imageName) || a.tag.localeCompare(b.tag) || a.imageID.localeCompare(b.imageID); } ================================================ FILE: pkg/rancher-desktop/backend/k3sHelper.ts ================================================ import crypto from 'crypto'; import events from 'events'; import fs from 'fs'; import os from 'os'; import path from 'path'; import stream from 'stream'; import tls from 'tls'; import util from 'util'; import { CustomObjectsApi, KubeConfig, V1ObjectMeta, findHomeDir, ApiException, } from '@kubernetes/client-node'; import { ActionOnInvalid } from '@kubernetes/client-node/dist/config_types'; import { net } from 'electron'; import _ from 'lodash'; import semver from 'semver'; import yaml from 'yaml'; import { Architecture, VMExecutor } from './backend'; import * as K8s from '@pkg/backend/k8s'; import { KubeClient } from '@pkg/backend/kube/client'; import { loadFromString, exportConfig } from '@pkg/backend/kubeconfig'; import { ContainerEngine } from '@pkg/config/settings'; import mainEvents from '@pkg/main/mainEvents'; import { isUnixError } from '@pkg/typings/unix.interface'; import DownloadProgressListener from '@pkg/utils/DownloadProgressListener'; import * as childProcess from '@pkg/utils/childProcess'; import { SemanticVersionEntry } from '@pkg/utils/kubeVersions'; import Latch from '@pkg/utils/latch'; import Logging from '@pkg/utils/logging'; import paths from '@pkg/utils/paths'; import { executable } from '@pkg/utils/resources'; import safeRename from '@pkg/utils/safeRename'; import { jsonStringifyWithWhiteSpace } from '@pkg/utils/stringify'; import { defined, RecursivePartial, RecursiveReadonly, RecursiveTypes } from '@pkg/utils/typeUtils'; import { showMessageBox } from '@pkg/window'; import type Electron from 'electron'; const console = Logging.k8s; /** * ShortVersion is the version string without any k3s suffixes, nor a "v" * prefix; this is the version we present to the user. */ export type ShortVersion = string; let isOnline = true; mainEvents.on('update-network-status', (connected) => { isOnline = connected; }); export interface ReleaseAPIEntry { tag_name: string; assets: { browser_download_url: string; name: string; }[]; } export class NoCachedK3sVersionsError extends Error { } const CURRENT_CACHE_VERSION = 2 as const; /** cacheData describes the JSON data we write to the cache. */ interface cacheData { cacheVersion?: typeof CURRENT_CACHE_VERSION; /** List of available versions; includes build information. */ versions: string[]; /** Mapping of channel labels to current version (excluding build information). */ channels: Record; } /** * RequiresRestartSeverityChecker is a function that will be used to determine * whether a given settings change will require a reset (i.e. deleting user * workloads). * @param currentValue The current value of the setting. * @param desiredValue The desired value of the setting. * @param allSettings The full merged settings object. * @returns 'restart' if a restart is required, 'reset' if a reset is required, * or false if no restart is required. */ type RequiresRestartSeverityChecker> = ( currentValue: RecursiveTypes[K], desiredValue: RecursiveTypes[K], allSettings: RecursiveReadonly, ) => 'restart' | 'reset' | false; /** * RequiresRestartCheckers defines a mapping of settings (in dot-separated form) * to a RequiresRestartSeverityChecker for the given setting. */ type RequiresRestartCheckers = { [K in keyof RecursiveTypes]?: RequiresRestartSeverityChecker; }; /** * ExtraRequiresReasons defines a mapping of settings (in dot-separated form) to * the current value (that does not always match the stored settings) and a * RequiresRestartSeverityChecker for the given setting. */ export type ExtraRequiresReasons = { [K in keyof RecursiveTypes]?: { current: RecursiveTypes[K]; severity?: RequiresRestartSeverityChecker; } }; /** * ChannelMapping is an internal structure to map a channel name to its * corresponding version. * * This only exists to aid in debugging. * This is only exported for tests. */ export class ChannelMapping { [channel: string]: semver.SemVer; [util.inspect.custom](depth: number, options: util.InspectOptionsStylized) { const entries = Object.entries(this).map(([channel, version]) => [channel, version.raw]); return util.inspect(Object.fromEntries(entries), { ...options, depth }); } } /** * Given a version, return the K3s build version. * * Note that this is only exported for testing. * @param version The version to parse * @returns The K3s build version */ export function buildVersion(version: semver.SemVer) { const [, numString] = /k3s(\d+)/.exec(version.build[0]) || [undefined, -1]; return parseInt(`${ numString || '-1' }`); } export default class K3sHelper extends events.EventEmitter { protected readonly channelApiUrl = 'https://update.k3s.io/v1-release/channels'; protected readonly channelApiAccept = 'application/json'; protected readonly releaseApiUrl = 'https://api.github.com/repos/k3s-io/k3s/releases?per_page=100'; protected readonly releaseApiAccept = 'application/vnd.github.v3+json'; protected readonly resourcesPath = path.join(paths.resources, 'k3s-versions.json'); protected readonly cachePath = path.join(paths.cache, 'k3s-versions.json'); protected readonly minimumVersion = new semver.SemVer('1.25.3'); /** * versionFromChannel is a mapping from the channel name to the latest (short) * version in that channel. */ protected versionFromChannel: Record = {}; constructor(arch: Architecture) { super(); this.arch = arch; } /** * Versions that we know to exist. This is indexed by the version string, * without any build information (since we only ever take the latest build). * Note that the key is in the form `1.0.0` (i.e. without the `v` prefix). */ protected versions: Record = {}; protected pendingNetworkSetup = Latch(); protected pendingInitialize: Promise | undefined; /** The current architecture. */ protected readonly arch: Architecture; /** * Read the cached data and fill out this.versions. * The cache file consists of an array of VersionEntry. */ protected async readCache() { try { let cacheData: cacheData; try { cacheData = JSON.parse(await fs.promises.readFile(this.cachePath, 'utf-8')); if (cacheData.cacheVersion !== CURRENT_CACHE_VERSION) { throw new Error(`Invalid cache version ${ cacheData.cacheVersion }`); } } catch (ex) { console.debug('Failed to read cached k3s versions; falling back to bundled versions list:', ex); cacheData = JSON.parse(await fs.promises.readFile(this.resourcesPath, 'utf-8')); } if (cacheData.cacheVersion !== CURRENT_CACHE_VERSION) { // If the cache format version is different, ignore the cache. console.debug(`Ignoring cache with invalid version ${ cacheData.cacheVersion }`); return; } for (const versionString of cacheData.versions) { const version = semver.parse(versionString); if (version && semver.gte(version, this.minimumVersion)) { this.versions[version.version] = new SemanticVersionEntry(version); } } for (const [channel, version] of Object.entries(cacheData.channels)) { if (!this.versions[version]) { console.debug(`Ignoring invalid version cache: ${ channel } has invalid version ${ version }`); continue; } this.versions[version].channels ??= []; this.versions[version].channels?.push(channel); this.versionFromChannel[channel] = version; } for (const entry of Object.values(this.versions)) { entry.channels?.sort(this.compareChannels); } } catch (ex) { console.error(`Error reading cached version data, discarding:`, ex); // Clear any versions we may have, to be populated as if we had no cache. this.versions = {}; } } /** Write this.versions into the cache file. */ protected async writeCache() { const cacheData: cacheData = { cacheVersion: CURRENT_CACHE_VERSION, versions: [], channels: {}, }; if (!cacheData.versions || !cacheData.channels) { throw new Error('Panic: invalid code flow'); } for (const [version, data] of Object.entries(this.versions)) { cacheData.versions.push(data.version.raw); for (const channel of data.channels ?? []) { cacheData.channels[channel] = version; } } cacheData.versions.sort((a, b) => semver.parse(a)?.compare(b) ?? a.localeCompare(b)); const serializedCacheData = jsonStringifyWithWhiteSpace(cacheData); await fs.promises.mkdir(paths.cache, { recursive: true }); await fs.promises.writeFile(this.cachePath, serializedCacheData, 'utf-8'); console.debug(`Wrote versions cache:`, cacheData); } /** The files we need to download for the current architecture. * images: an array of potential files in order of most preferred to least preferred */ protected get filenames() { switch (this.arch) { case 'x86_64': return { exe: 'k3s', images: ['k3s-airgap-images-amd64.tar.zst', 'k3s-airgap-images-amd64.tar'], checksum: 'sha256sum-amd64.txt', }; case 'aarch64': return { exe: 'k3s-arm64', images: ['k3s-airgap-images-arm64.tar.zst', 'k3s-airgap-images-arm64.tar'], checksum: 'sha256sum-arm64.txt', }; } } /** * Process one version entry retrieved from GitHub, inserting it into the * cache. This will not add any channel labels. * @param entry The GitHub API response entry to process. * @returns Whether more entries should be fetched. Note that we will err on * the side of getting more versions if we are unsure. */ protected processVersion(entry: ReleaseAPIEntry): boolean { const version = semver.parse(entry.tag_name); if (!version) { console.log(`Skipping empty version ${ entry.tag_name }`); return true; } if (version.prerelease.length > 0) { // Skip any pre-releases. console.log(`Skipping pre-release ${ version.raw }`); return true; } if (version.compare(this.minimumVersion) < 0) { console.log(`Version ${ version } is less than the minimum ${ this.minimumVersion }, skipping.`); // We may have new patch versions for really old releases; fetch more. return true; } if (!/^v?[0-9.]+(?:-rc\d+)?\+k3s\d+$/.test(version.raw)) { console.log(`Version ${ version.raw } looks like an erroneous version, skipping.`); return true; } const build = buildVersion(version); const oldVersion = this.versions[version.version]; if (oldVersion) { const oldBuild = buildVersion(oldVersion.version); if (build < oldBuild) { console.log(`Skipping old version ${ version.raw }, have build ${ oldVersion.version.raw }`); // Since we read from newest first, we may end up with older builds of // some newer release, but still need to fetch the last build of an // older release. So we still need to fetch more. return true; } if (build === oldBuild) { // If we see the _exact_ same version, we've found something we've // already seen before for sure. This is the only situation where we // can be sure that we will not find more useful versions. console.log(`Found old version ${ version.raw }, stopping.`); console.debug(util.inspect({ version: version.raw, all: Object.keys(this.versions) })); return false; } } // Check that this release has all the assets we expect. if (entry.assets.find(ea => ea.name === this.filenames.exe) && entry.assets.find(ea => ea.name === this.filenames.checksum)) { const foundImage = this.filenames.images.find(name => entry.assets.some(v => v.name === name)); if (foundImage) { this.versions[version.version] = new SemanticVersionEntry(version); console.log(`Adding version ${ version.raw } - ${ foundImage }`); } else { console.debug(`Skipping version ${ version.raw } due to missing image`); } } else { console.debug(`Skipping version ${ version.raw } due to missing files`); } return true; } /** * Produce a promise that is resolved after a short delay, used for retrying * API requests when GitHub API requests are being rate-limited. */ protected async delayForWaitLimiting(duration = 1_000): Promise { // This is a separate method so that we could override it in the tests. // Jest cannot override setTimeout: https://stackoverflow.com/q/52727220/ await util.promisify(setTimeout)(duration); } /** * Compare two channel names for sorting. */ protected compareChannels(a: string, b: string) { // The names are either a word ("stable", "testing", etc.) or a branch // ("v1.2", etc.). The sort should be words first, then branch. For words, // list "stable" before anything else. We assume no release can match two // branch channels at once. const versionRegex = /^v(?\d+)\.(?\d+)/; if (a === 'stable' || b === 'stable') { // sort "stable" at the front return a === 'stable' ? -1 : 1; } if (versionRegex.test(a) || versionRegex.test(b)) { return versionRegex.test(a) ? 1 : -1; } return a.localeCompare(b); } /** * Fetch the list of available Kubernetes versions. * @throws If there were issues fetching the list of versions. */ protected async updateCache(): Promise { try { let wantMoreVersions = true; let url = this.releaseApiUrl; const channelMapping = new ChannelMapping(); await this.waitForNetwork(); await this.readCache(); console.log(`Updating release version cache with ${ Object.keys(this.versions).length } items in cache`); let channelResponse: Response; try { channelResponse = await net.fetch(this.channelApiUrl, { headers: { Accept: this.channelApiAccept } }); } catch (ex: any) { console.log(`updateCache: error: ${ ex }`); if (!isOnline) { return; } throw ex; } if (channelResponse.ok) { const ValidResourceTypes = ['channel', 'channels']; const DataTypeChannel = 'channel'; interface ChannelResponse { resourceType: string; data?: { type: typeof DataTypeChannel; name: string; latest: string; }[]; } const channels: ChannelResponse = await channelResponse.json(); console.debug(`Got K3s update channel data: ${ channels.data?.map(ch => ch.name) }`); if (!ValidResourceTypes.includes(channels.resourceType)) { throw new Error(`Channel response does not have correct resource type: ${ channels.resourceType }`); } for (const channel of channels.data ?? []) { if (channel.type !== DataTypeChannel) { // The channel entry is invalid; ignore it. continue; } const version = semver.parse(channel.latest); if (version) { channelMapping[channel.name] = version; } } console.debug('Recommended versions:', channelMapping); } while (wantMoreVersions && url) { const headers: HeadersInit = { Accept: this.releaseApiAccept }; if (process.env.GITHUB_TOKEN) { headers.Authorization = `Bearer ${ process.env.GITHUB_TOKEN }`; } const response = await net.fetch(url, { headers }); console.debug(`Fetching releases from ${ url } -> ${ response.statusText }`); if (!response.ok) { if (response.status === 403 && response.headers.get('X-RateLimit-Remaining') === '0') { // We hit the rate limit; try again after a delay. // If given, the rate limit reset time is in seconds since UTC epoch. const resetTime = parseInt(response.headers.get('X-RateLimit-Reset') ?? '0', 10); await this.delayForWaitLimiting(Math.min(1_000, resetTime * 1_000 - Date.now())); continue; } throw new Error(`Could not fetch releases: ${ response.statusText }`); } const linkHeader = response.headers.get('Link'); if (linkHeader) { const [, nextURL] = /<([^>]+)>; rel="next"/.exec(linkHeader) || []; url = nextURL; } else { url = ''; } wantMoreVersions = true; for (const entry of (await response.json()) as ReleaseAPIEntry[]) { if (!this.processVersion(entry)) { wantMoreVersions = false; break; } } } // Apply channel data for (const [channel, version] of Object.entries(channelMapping)) { const entry = this.versions[version.version]; if (entry) { if (this.versionFromChannel[channel] && this.versionFromChannel[channel] !== version.version) { const otherEntry = this.versions[this.versionFromChannel[channel]]; if (otherEntry?.channels) { otherEntry.channels = otherEntry.channels.filter(ch => ch !== channel); if (otherEntry.channels.length === 0) { delete otherEntry.channels; } } } entry.channels ??= []; if (!entry.channels.includes(channel)) { entry.channels.push(channel); entry.channels.sort(this.compareChannels); } this.versionFromChannel[channel] = version.version; } } console.log(`Got ${ Object.keys(this.versions).length } versions.`); await this.writeCache(); this.emit('versions-updated'); } catch (e) { console.error(e); throw e; } } /** * Mark the network as ready; this is used as a barrier to ensure we do not * make network requests before setup is complete. */ networkReady() { this.pendingNetworkSetup.resolve(); } /** * This function waits for the `networkReady()` method to be called. */ protected async waitForNetwork() { // `this.pendingNetworkSetup` is a Promise with an extra method that can be // used to resolve the promise. By awaiting on it, we pause execution until // `this.networkReady()` is called (which resolves the promise). await this.pendingNetworkSetup; } /** * Initialize the version fetcher. * @returns A promise that is resolved when the initialization is complete. */ initialize(): Promise { if (!this.pendingInitialize) { this.versionFromChannel = {}; this.pendingInitialize = (async() => { await this.readCache(); if (Object.keys(this.versions).length > 0) { // Start a cache update asynchronously without waiting for it this.updateCache().catch((ex: any) => { console.log(`updateCache failed: ${ ex }`); }); return; } try { await this.updateCache(); } catch (ex) { console.log(`Ignoring failure to get initial versions list: ${ ex }`); // At this point this.versions is still empty. } })(); } return this.pendingInitialize; } /** * Return the version of k3s current installed, if available. */ static async getInstalledK3sVersion(executor: VMExecutor): Promise { let stdout: string; try { stdout = await executor.execCommand({ capture: true, expectFailure: true }, '/usr/local/bin/k3s', '--version'); } catch (ex) { console.debug(`Failed to get k3s version: ${ ex } - assuming not installed.`); return undefined; } const line = stdout.split('/\r?\n/').find(line => line.startsWith('k3s version ')); if (!line) { console.debug(`K3s version not in --version output.`); return undefined; } const match = /^k3s version v?((?:\d+\.?)+\+k3s\d+)/.exec(line); if (!match) { console.debug(`Invalid k3s version line: ${ line.trim() }`); return undefined; } console.debug(`Got installed k3s version: ${ match[1] } (${ match[0] })`); return match[1]; } /** * The versions that are available to install. * @note The list will be empty if the machine is offline and we have no * cached versions. */ get availableVersions(): Promise { return (async() => { await this.initialize(); const wrappedVersions = Object.values(this.versions); const finalOptions = isOnline ? wrappedVersions : await K3sHelper.filterVersionsAgainstCache(wrappedVersions); return finalOptions.sort((a, b) => b.version.compare(a.version)); })(); } static cachedVersionsOnly(): Promise { return Promise.resolve(!isOnline); } static async filterVersionsAgainstCache(fullVersionList: SemanticVersionEntry[]): Promise { try { const cacheDir = path.join(paths.cache, 'k3s'); const k3sFilenames = (await fs.promises.readdir(cacheDir)) .filter(dirname => /^v\d+\.\d+\.\d+\+k3s\d+$/.test(dirname)); const versionSet = new Set(k3sFilenames.map(filename => semver.parse(filename)?.version) .filter(defined)); return fullVersionList.filter(versionEntry => versionSet.has(versionEntry.version.version)); } catch (e: any) { if (e.code === 'ENOENT') { return []; } console.log('filterVersionsAgainstCache: Got exception:', e); throw e; } } /** The download URL prefix for K3s releases. */ protected get downloadUrl() { return 'https://github.com/k3s-io/k3s/releases/download'; } /** * Variable to keep track of download progress */ progress = { exe: { current: 0, max: 0 }, images: { current: 0, max: 0 }, checksum: { current: 0, max: 0 }, }; /** * Find the cached version closest to the desired version. * @param desiredVersion The semver of the version of k3s the system would prefer to use, * with a '+k3s###' suffix * @returns A semver of the version to use, also with a '+k3s###' suffix */ static async selectClosestImage(desiredVersion: semver.SemVer): Promise { const cacheDir = path.join(paths.cache, 'k3s'); const k3sFilenames = (await fs.promises.readdir(cacheDir)) .filter(dirname => /^v\d+\.\d+\.\d+\+k3s\d+$/.test(dirname)); return this.selectClosestSemVer(desiredVersion, k3sFilenames); } /** * Given a semver for the desired version, and a list of names representing other * k3s versions (matching /v\d+\.\d+\.\d+\+k3s\d+/), return the semver for the name * that is considered closest to the desired version: * * @precondition the desired version wasn't found * @param desiredVersion a semver for the version currently specified in the config * @param k3sNames typically a list of names like 'v1.2.3+k3s4' * @returns {semver.SemVer} the oldest version newer than the desired version * If there is more than one such version, favor the one with the highest '+k3s' build version * If there are none, the newest version older than the desired version * @throws {NoCachedK3sVersionsError} if no names are suitable */ protected static selectClosestSemVer(desiredVersion: semver.SemVer, k3sNames: string[]): semver.SemVer { const existingVersions = k3sNames.map(filename => semver.parse(filename)).filter(defined); if (existingVersions.length === 0) { throw new NoCachedK3sVersionsError(); } existingVersions.sort((v1, v2): number => { return v1.compare(v2) || this.compareBuildVersions(v1, v2); }); const filteredVersions = this.keepHighestBuildVersion(existingVersions); const firstAcceptableVersion = filteredVersions.find(v => v.compare(desiredVersion) >= 0); return firstAcceptableVersion ?? filteredVersions[filteredVersions.length - 1]; } // A comparator when the versions are the same so we need to compare the numeric part of the '+k3s...' parts protected static compareBuildVersions(v1: semver.SemVer, v2: semver.SemVer): number { return this.k3sValue(v1) - this.k3sValue(v2); } protected static k3sValue(v: semver.SemVer): number { try { return parseInt((v.build[0]).replace('k3s', ''), 10) || 0; } catch { return 0; } } /** * Normally we should have only one build version in the cache for any MAJOR.MINOR.PATCH * But if we don't, ignore the lower build versions. This code is used to simplify the selection process * by removing the lower-build versions from consideration. * @precondition existingVersions is sorted such that `a[i].compare(a[i+1]) <= 0` for i in 0..a.length - 2 * @param existingVersions {Array} versions to choose from * @returns {Array}: existingVersions, * with lower-build versions culled out as described above. */ protected static keepHighestBuildVersion(existingVersions: semver.SemVer[]): semver.SemVer[] { // Keep only the highest build for each version return existingVersions.filter((v, i) => { const next = existingVersions[i + 1]; return next === undefined || v.compare(next) < 0; }); } /** * Ensure that the K3s assets have been downloaded into the cache, which is * at (paths.cache())/k3s. * @param version The version of K3s to download, without the k3s suffix. */ async ensureK3sImages(version: semver.SemVer): Promise { const cacheDir = path.join(paths.cache, 'k3s'); console.log(`Ensuring images available for K3s ${ version }`); const verifyChecksums = async(dir: string): Promise => { try { const sumFile = await fs.promises.readFile(path.join(dir, this.filenames.checksum), 'utf-8'); const sums: Record = {}; for (const line of sumFile.split(/[\r\n]+/)) { const match = /^\s*([0-9a-f]+)\s+(.*)/i.exec(line.trim()); if (!match) { continue; } const [, sum, filename] = match; sums[filename] = sum; } let existsIndex; for (let index = 0; typeof existsIndex === 'undefined' && index < this.filenames.images.length; index++) { try { await fs.promises.access(path.join(dir, this.filenames.images[index]), fs.constants.R_OK); existsIndex = index; } catch { // ignore access error and try next iteration if any } } if (typeof existsIndex === 'undefined') { existsIndex = 0; } const promises = [this.filenames.exe, this.filenames.images[existsIndex]].map(async(filename) => { const hash = crypto.createHash('sha256'); await new Promise((resolve) => { hash.on('finish', resolve); fs.createReadStream(path.join(dir, filename)).pipe(hash); }); const digest = hash.digest('hex'); if (digest.localeCompare(sums[filename], undefined, { sensitivity: 'base' }) !== 0) { return new Error(`${ filename } has invalid digest ${ digest }, expected ${ sums[filename] }`); } return null; }); return (await Promise.all(promises)).filter(x => x)[0]; } catch (ex) { if ((ex as NodeJS.ErrnoException).code !== 'ENOENT') { throw ex; } if (!(ex instanceof Error)) { return null; } return ex; } }; await fs.promises.mkdir(cacheDir, { recursive: true }); if (!await verifyChecksums(path.join(cacheDir, version.raw))) { console.log(`Cache at ${ cacheDir } is valid.`); return; } const workDir = await fs.promises.mkdtemp(path.join(cacheDir, `tmp-${ version.raw }-`)); try { await Promise.all(Object.entries(this.filenames).map(async([filekey, filename]) => { const namearray = Array.isArray(filename) ? filename : [filename]; let outPath; let response: Response | undefined; for (const name of namearray) { const fileURL = `${ this.downloadUrl }/${ version.raw }/${ name }`; outPath = path.join(workDir, name); console.log(`Will attempt to download ${ filekey } ${ fileURL } to ${ outPath }`); response = await net.fetch(fileURL); if (response.ok) { break; } } if (!response || !outPath) { throw new Error(`Error downloading ${ filename } ${ version }: No ${ filekey }s found`); } // response.body implements ReadableStream, but it uses a different set // of typings so TypeScript doesn't understand it natively. const body: NodeJS.ReadableStream | null = response.body as any; if (!body) { throw new Error(`Error downloading ${ filename } ${ version }: No response body`); } const progresskey = filekey as keyof typeof K3sHelper.prototype.filenames; const status = this.progress[progresskey]; status.current = 0; const progress = new DownloadProgressListener(status); const writeStream = fs.createWriteStream(outPath); status.max = parseInt(response.headers.get('Content-Length') || '0'); await util.promisify(stream.pipeline)(body, progress, writeStream); })); const error = await verifyChecksums(workDir); if (error) { console.log('Error verifying checksums after download', error); throw error; } await safeRename(workDir, path.join(cacheDir, version.raw)); } finally { await fs.promises.rm(workDir, { recursive: true, maxRetries: 3, force: true, }); } } /** * Wait the K3s server to be ready after starting up. * * This will check that the proper TLS certificate is generated by K3s; this * is required as if the VM IP address changes, K3s will use a certificate * that is only valid for the old address for a short while. If we attempt to * communicate with the cluster at this point, things will fail. * * @param getHost A function to return the IP address that K3s will listen on * internally. This may be called multiple times, as the * address may not be ready yet. * @param port The port that K3s will listen on. */ async waitForServerReady(getHost: () => Promise, port: number): Promise { let host: string | undefined; console.log(`Waiting for K3s server to be ready on port ${ port }...`); while (true) { try { host = await getHost(); if (typeof host === 'undefined') { await util.promisify(setTimeout)(500); continue; } await new Promise((resolve, reject) => { const socket = tls.connect( { host, port, rejectUnauthorized: false, }, () => { const cert = socket.getPeerCertificate(); // Check that the certificate contains a SubjectAltName that // includes the host we're looking for; when the server starts, it // may be using an obsolete certificate from a previous run that // doesn't include the current IP address. const names = cert.subjectaltname?.split(',')?.map(s => s.trim()) ?? []; const acceptable = [`IP Address:${ host }`, `DNS:${ host }`]; if (!names.some(name => acceptable.includes(name))) { return reject({ code: 'EAGAIN' }); } // Check that the certificate is valid; if the IP address _didn't_ // change, but the cert is old, we need to wait for it to be // regenerated. if (Date.parse(cert.valid_from).valueOf() >= Date.now()) { return reject({ code: 'EAGAIN' }); } resolve(); }); socket.on('error', reject); }); break; } catch (error) { if (!isUnixError(error)) { console.error(error); return; } switch (error.code) { case 'EAGAIN': case 'ECONNREFUSED': case 'ECONNRESET': break; default: // Unrecognized error; log but continue waiting. console.error(error); } await util.promisify(setTimeout)(1_000); } } console.log(`The K3s server is ready on ${ host }:${ port }.`); } /** * Find the kubeconfig file containing the given context; if none is found, * return the default kubeconfig path. * @param contextName The name of the context to look for */ static async findKubeConfigToUpdate(contextName: string): Promise { const candidatePaths = process.env.KUBECONFIG?.split(path.delimiter) || []; for (const kubeConfigPath of candidatePaths) { const config = new KubeConfig(); try { config.loadFromFile(kubeConfigPath, { onInvalidEntry: ActionOnInvalid.FILTER }); if (config.contexts.find(ctx => ctx.name === contextName)) { return kubeConfigPath; } } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { throw err; } } } const home = findHomeDir(); if (home) { const kubeDir = path.join(home, '.kube'); await fs.promises.mkdir(kubeDir, { recursive: true }); return path.join(kubeDir, 'config'); } throw new Error(`Could not find a kubeconfig`); } /** * Update the user's kubeconfig such that the K3s context is available and * set as the current context. This assumes that K3s is already running. * * @param configReader A function that returns the kubeconfig from the K3s VM. */ async updateKubeconfig(configReader: () => Promise): Promise { const contextName = 'rancher-desktop'; const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rancher-desktop-kubeconfig-')); try { const workPath = path.join(workDir, 'kubeconfig'); // For some reason, using KubeConfig.loadFromFile presents permissions // errors; doing the same ourselves seems to work better. Since the file // comes from the WSL container, it must not contain any paths, so there // is no need to fix it up. This also lets us use an external function to // read the kubeconfig. const workConfig = new KubeConfig(); const workContents = await configReader(); workConfig.loadFromString(workContents); // @kubernetes/client-node doesn't have an API to modify the configs... const contextIndex = workConfig.contexts.findIndex(context => context.name === workConfig.currentContext); if (contextIndex >= 0) { const context = workConfig.contexts[contextIndex]; const userIndex = workConfig.users.findIndex(user => user.name === context.user); const clusterIndex = workConfig.clusters.findIndex(cluster => cluster.name === context.cluster); if (userIndex >= 0) { workConfig.users[userIndex] = { ...workConfig.users[userIndex], name: contextName }; } if (clusterIndex >= 0) { workConfig.clusters[clusterIndex] = { ...workConfig.clusters[clusterIndex], name: contextName }; } workConfig.contexts[contextIndex] = { ...context, name: contextName, user: contextName, cluster: contextName, }; workConfig.currentContext = contextName; } const userPath = await K3sHelper.findKubeConfigToUpdate(contextName); const userConfig = new KubeConfig(); // @kubernetes/client-node throws when merging things that already exist const merge = (list: T[], additions: T[]) => { for (const addition of additions) { const index = list.findIndex(item => item.name === addition.name); if (index < 0) { list.push(addition); } else { list[index] = addition; } } }; console.log(`Updating kubeconfig ${ userPath }...`); try { // Don't use loadFromFile() because it calls MakePathsAbsolute(). // Use custom loadFromString() that supports the `proxy-url` cluster field. loadFromString(userConfig, fs.readFileSync(userPath, 'utf8'), { onInvalidEntry: ActionOnInvalid.FILTER }); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.log(`Error trying to load kubernetes config file ${ userPath }:`, err); } // continue to merge into an empty userConfig == `{ contexts: [], clusters: [], users: [] }` } merge(userConfig.contexts, workConfig.contexts); merge(userConfig.users, workConfig.users); merge(userConfig.clusters, workConfig.clusters); userConfig.currentContext ||= contextName; // Use custom exportConfig() that supports the `proxy-url` cluster field. const userYAML = this.ensureContentsAreYAML(exportConfig(userConfig)); const writeStream = fs.createWriteStream(workPath, { mode: 0o600 }); await new Promise((resolve, reject) => { writeStream.on('error', reject); writeStream.on('finish', resolve); writeStream.end(userYAML, 'utf-8'); }); await safeRename(workPath, userPath); } finally { await fs.promises.rm(workDir, { recursive: true, force: true, maxRetries: 10, }); } } /** * We normally parse all the config files, yaml and json, with yaml.parse, so yaml.parse * should work with json here. */ protected ensureContentsAreYAML(contents: string): string { try { return yaml.stringify(yaml.parse(contents)); } catch (err) { console.log(`Error in k3sHelper.ensureContentsAreYAML: ${ err }`); } return contents; } /** * Delete state related to Kubernetes. This will ensure that images are not * deleted. * @param executor The interface to run commands in the VM. */ async deleteKubeState(executor: VMExecutor) { const directories = [ '/var/lib/kubelet', // https://github.com/kubernetes/kubernetes/pull/86689 // We need to keep /var/lib/rancher/k3s/agent/containerd for the images. '/var/lib/rancher/k3s/data', '/var/lib/rancher/k3s/server', '/var/lib/rancher/k3s/storage', '/etc/rancher/k3s', '/run/k3s', ]; console.log(`Attempting to remove K3s state: ${ directories.sort().join(' ') }`); await Promise.all(directories.map(d => executor.execCommand({ root: true }, 'rm', '-rf', d))); } /** * Manually uninstall the K3s-installed copy of Traefik, if it exists. * This exists to work around https://github.com/k3s-io/k3s/issues/5103 */ async uninstallHelmChart(client: KubeClient, ownerName: string) { const deadline = Date.now() + 10 * 60 * 1_000; // If the Kubernetes server is not ready yet, we need to retry until it is. // However, don't attempt that forever; only loop until we hit a deadline. while (Date.now() < deadline) { try { const customApi = client.k8sClient.makeApiClient(CustomObjectsApi); const response = await customApi.listNamespacedCustomObject({ group: 'helm.cattle.io', version: 'v1', namespace: 'kube-system', plural: 'helmcharts', }); const charts: V1HelmChart[] = response?.items ?? []; await Promise.all(charts.filter((chart) => { const annotations = chart.metadata?.annotations ?? {}; return chart.metadata?.name && (annotations['objectset.rio.cattle.io/owner-name'] === ownerName); }).map((chart) => { const name = chart.metadata?.name; if (name) { console.debug(`Will delete helm chart ${ name }`); return customApi.deleteNamespacedCustomObject({ group: 'helm.cattle.io', version: 'v1', namespace: 'kube-system', plural: 'helmcharts', name, }); } }).filter(defined)); return; } catch (ex) { if (ex instanceof ApiException && [429, 503].includes(ex.code)) { const body = typeof ex.body === 'string' ? JSON.parse(ex.body) : ex.body; const delay = body?.details?.retryAfterSeconds || 1; console.debug(`Got Service Unavailable (${ body.reason }: ${ body.message }), retrying...`); await util.promisify(setTimeout)(delay * 1_000); continue; } console.error(`Error uninstalling ${ ownerName }`, ex); return; } } console.error('Timed out uninstalling Traefik, giving up'); } /** * Rancher Desktop's exposed `kubectl` utility is actually a wrapper around `kuberlr`, * which guarantees that the actual true `kubectl` utility is compatible * with the current version of kubernetes on the server. * * Calling `kubectl --context rancher-desktop cluster-info` is a good way to verify * that the correct version of `kubectl` is available, or to let the user know there * was a problem downloading it. * * @param version */ async getCompatibleKubectlVersion(version: semver.SemVer): Promise { const commandArgs = ['--context', 'rancher-desktop', 'cluster-info']; try { const { stdout, stderr } = await childProcess.spawnFile(executable('kubectl'), commandArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); if (stdout) { console.info(stdout); } if (stderr) { console.log(stderr); } } catch (ex: any) { console.error(`Error priming kuberlr: ${ ex }`); console.log(`Output from kuberlr:\nex.stdout: [${ ex.stdout ?? 'none' }],\nex.stderr: [${ ex.stderr ?? 'none' }]`); const pattern = /Right kubectl missing, downloading.+Error while trying to get contents of .+\/kubernetes-release/s; if (pattern.test(ex.stderr)) { const major = version.major; const minor = version.minor; const lowMinor = minor === 0 ? 0 : minor - 1; const highMinor = minor + 1; const homeDirName = os.platform().startsWith('win') ? (findHomeDir() ?? '%HOME%') : '~'; const kuberlrCacheDirName = `${ os.platform() }-${ process.env.M1 ? 'arm64' : 'amd64' }`; const options: Electron.MessageBoxOptions = { message: "Can't download a compatible version of kubectl in offline-mode", detail: `Please acquire a version in the range ${ major }.${ lowMinor } - ${ major }.${ highMinor } and install in '${ path.join(homeDirName, '.kuberlr', kuberlrCacheDirName) }'`, type: 'error', buttons: ['OK'], title: 'Network failure', }; await showMessageBox(options, true); } else { console.log('Failed to match a kuberlr network access issue.'); } } } /** * Helper for implementing KubernetesBackend.requiresRestartReasons */ requiresRestartReasons( currentSettings: K8s.BackendSettings, desiredSettings: RecursivePartial, checkers: RequiresRestartCheckers, extras: ExtraRequiresReasons = {}, ): K8s.RestartReasons { const results: K8s.RestartReasons = {}; const NotFound = Symbol('not-found'); const mergedSettings = _.merge({}, currentSettings, desiredSettings); function restartIfKubernetesEnabled() { return mergedSettings.kubernetes.enabled ? 'restart' : false; } /** * defaultRestartReasonCheckers contains the restart reason checkers shared * between backends. */ const defaultRestartReasonCheckers: RequiresRestartCheckers = { 'containerEngine.mobyStorageDriver': (current, desired, allSettings) => { // We only need to restart if running moby. return allSettings.containerEngine.name === ContainerEngine.MOBY ? 'restart' : false; }, 'kubernetes.version': (current, desired, allSettings) => { if (!allSettings.kubernetes.enabled) { return false; } return semver.gt(current || '0.0.0', desired) ? 'reset' : 'restart'; }, 'containerEngine.allowedImages.enabled': undefined, 'containerEngine.name': undefined, 'experimental.containerEngine.webAssembly.enabled': undefined, 'experimental.kubernetes.options.spinkube': undefined, 'kubernetes.enabled': undefined, 'kubernetes.options.flannel': restartIfKubernetesEnabled, 'kubernetes.options.traefik': restartIfKubernetesEnabled, 'kubernetes.port': restartIfKubernetesEnabled, }; /** * Check the given settings against the last-applied settings to see if we * need to restart the backend. * @param key The identifier to use for the UI. */ function cmp(key: K, checker?: RequiresRestartSeverityChecker) { const current: RecursiveTypes[K] | typeof NotFound = _.get(currentSettings, key, NotFound) as any; const desired: RecursiveTypes[K] | typeof NotFound = _.get(desiredSettings, key, NotFound) as any; if (current === NotFound) { throw new Error(`Invalid restart check: path ${ path } not found on current values`); } if (desired === NotFound) { // desiredSettings does not contain the full set. return; } if (!_.isEqual(current, desired)) { const severity = checker ? checker(current, desired, mergedSettings) : 'restart'; if (severity) { results[key] = { current, desired, severity }; } } } for (const [key, checker] of Object.entries({ ...defaultRestartReasonCheckers, ...checkers })) { if (checker === null) { // The custom checker wants to delete a default checker. continue; } // We need the casts here because TypeScript can't match up the key with // its corresponding checker. cmp(key as any, checker as any); } for (const [key, entry] of Object.entries(extras)) { if (!entry) { // The list is hard-coded; getting here means a programming error. throw new Error(`Invalid requiresRestartReasons extra key ${ key }`); } const desired = _.get(desiredSettings, key); const { current, severity } = entry; if (!_.isEqual(current, desired)) { results[key as keyof K8s.RestartReasons] = { current, desired, severity: severity ? (severity as any)(current, desired) : 'restart', }; } } return results; } } interface V1HelmChart { apiVersion?: 'helm.cattle.io/v1'; kind?: 'HelmChart'; metadata?: V1ObjectMeta; } ================================================ FILE: pkg/rancher-desktop/backend/k8s.ts ================================================ import semver from 'semver'; import { BackendSettings, RestartReasons } from './backend'; import K3sHelper, { ExtraRequiresReasons } from './k3sHelper'; import EventEmitter from '@pkg/utils/eventEmitter'; import { SemanticVersionEntry } from '@pkg/utils/kubeVersions'; import { RecursivePartial } from '@pkg/utils/typeUtils'; import type { ServiceEntry } from './kube/client'; export { State, BackendError as KubernetesError } from './backend'; export type { BackendSettings, FailureDetails, RestartReasons, BackendProgress as KubernetesProgress, } from './backend'; export type { ServiceEntry } from './kube/client'; /** * KubernetesBackendEvents describes the events that may be emitted by a * Kubernetes backend (as an EventEmitter). Each property name is the name of * an event, and the property type is the type of the callback function expected * for the given event. */ export interface KubernetesBackendEvents { /** * Emitted when the set of Kubernetes services has changed. */ 'service-changed'(services: ServiceEntry[]): void; /** * Emitted when an error related to the port forwarding server has occurred. */ 'service-error'(service: ServiceEntry, errorMessage: string): void; /** * Emitted when the versions of Kubernetes available has changed. */ 'versions-updated'(): void; /** * Emitted when k8s is running on a new port */ 'current-port-changed'(port: number): void; } export interface KubernetesBackend extends EventEmitter, KubernetesBackendPortForwarder { /** * The versions that are available to install, sorted as would be displayed to * the user. */ availableVersions: Promise; /** * Used to let the UI know whether it was sent all potentially supported k8s versions. * If this returns true, it means we're only telling the UI which versions we have cached. */ cachedVersionsOnly(): Promise; /** The version of Kubernetes that is currently installed. */ version: string; /** * The port the Kubernetes server will listen on; this may not reflect the * port correctly if the server is not active. */ readonly desiredPort: number; /** * Fetch the list of services currently known to Kubernetes. * @param namespace The namespace containing services; omit this to * return services across all namespaces. */ listServices(namespace?: string): ServiceEntry[]; /** * Download the version of K3s as specified in the settings. * @returns The version, or undefined if a downgrade is required but the user * did not agree to it; plus a boolean describing if the result is a * downgrade. */ download(config: BackendSettings): Promise; /** * Delete Kubernetes data that may cause issues if we were to move to the * given version. */ deleteIncompatibleData(desiredVersion: semver.SemVer): Promise; /** * Install a pre-downloaded version of Kubernetes. */ install(config: BackendSettings, kubernetesVersion: semver.SemVer, allowSudo: boolean): Promise; /** * Start running a pre-installed version of Kubernetes. */ start(config: BackendSettings, kubernetesVersion: semver.SemVer): Promise; /** * Stop the Kubernetes backend. */ stop(): Promise; /** * Assuming Kubernetes was halted, clean up any data that would be stale. */ cleanup(): Promise; /** * Remove Kubernetes-specific data, assuming it has already been stopped. */ reset(): Promise; /** * Calculate any reasons that may require us to restart the backend, had the * given new configuration been applied on top of the existing old configuration. */ requiresRestartReasons(oldConfig: BackendSettings, newConfig: RecursivePartial, extras?: ExtraRequiresReasons): Promise; readonly k3sHelper: K3sHelper; } export interface KubernetesBackendPortForwarder { /** * Forward a single service port, returning the resulting local port number. * @param namespace The namespace containing the service to forward. * @param service The name of the service to forward. * @param k8sPort The internal port of the service to forward. * @param hostPort The host port to listen on for the forwarded port. Pass 0 for a random port. * @returns The port listening on localhost that forwards to the service. */ forwardPort(namespace: string, service: string, k8sPort: number | string, hostPort: number): Promise; /** * Cancel an existing port forwarding. * @param namespace The namespace containing the service to forward. * @param service The name of the service to forward. * @param k8sPort The internal port of the service to forward. */ cancelForward(namespace: string, service: string, k8sPort: number | string): Promise; } ================================================ FILE: pkg/rancher-desktop/backend/kube/client.ts ================================================ // This file contains wrappers to interact with the installed Kubernetes cluster import events from 'events'; import net from 'net'; import stream from 'stream'; import util from 'util'; import * as k8s from '@kubernetes/client-node'; import Logging from '@pkg/utils/logging'; import { defined } from '@pkg/utils/typeUtils'; const console = Logging.k8s; interface clientError { error: string; } function isClientError(val: any): val is clientError { return 'error' in val; } /** * ErrorSuppressingStdin wraps a socket such that when the 'data' event handler * throws, we can suppress the output so we do not get a dialog box, but rather * just break silently. */ class ErrorSuppressingStdin extends stream.Readable { #socket: net.Socket; #listeners: Record void> = {}; /** * @param socket The underlying socket to forward to. */ constructor(socket: net.Socket) { super(); this.#socket = socket; this.on('newListener', (eventName) => { if (!(eventName in this.#listeners)) { this.#listeners[eventName] = this.listener.bind(this, eventName); this.#socket.on(eventName, this.#listeners[eventName]); } }); this.on('removeListener', (eventName) => { if (this.listenerCount(eventName) < 1) { this.#socket.removeListener(eventName, this.#listeners[eventName]); delete this.#listeners[eventName]; } }); } listener(eventName: string, ...args: any[]) { for (const listener of this.listeners(eventName)) { try { listener(...args); } catch (e) { console.error(isClientError(e) ? e.error : e); } } } _read(size: number): void { this.#socket.read(size); } read(size?: number): any { return this.#socket.read(size); } } /** * ForwardingMap holds the outstanding listeners used to do port forwarding; * this mainly exists for type safety / ensuring we get the keys correct. */ class ForwardingMap { protected map = new Map(); /** * Get a forwarding entry. * @param namespace The namespace to forward to. * @param endpoint The endpoint in the namespace to forward to. * @param port The port to forward to on the endpoint. */ get(namespace: string | undefined, endpoint: string, port: number | string) { return this.map.get(`${ namespace || 'default' }/${ endpoint }:${ port }`); } /** * Set a forwarding entry. * @param namespace The namespace to forward to. * @param endpoint The endpoint in the namespace to forward to. * @param port The port to forward to on the endpoint. * @param server The value to set. */ set(namespace: string | undefined, endpoint: string, port: number | string, server: net.Server) { return this.map.set(`${ namespace || 'default' }/${ endpoint }:${ port }`, server); } /** * Delete a forwarding entry. * @param namespace The namespace to forward to. * @param endpoint The endpoint in the namespace to forward to. * @param port The port to forward to on the endpoint. */ delete(namespace: string | undefined, endpoint: string, port: number | string) { return this.map.delete(`${ namespace || 'default' }/${ endpoint }:${ port }`); } /** * Check if a forwarding entry already exists. * @param namespace The namespace to forward to. * @param endpoint The endpoint in the namespace to forward to. * @param port The port to forward to on the endpoint. */ has(namespace: string | undefined, endpoint: string, port: number | string) { return this.map.has(`${ namespace || 'default' }/${ endpoint }:${ port }`); } /** * Iterate through the entries. */ * [Symbol.iterator](): IterableIterator<[string, string, number | string, net.Server]> { const iter = this.map[Symbol.iterator](); for (const [key, server] of iter) { const match = /^([^/]*)\/([^:]+):(.+?)$/.exec(key); if (match) { const [namespace, endpoint, portString] = match; const port = /^\d+$/.test(portString) ? parseInt(portString) : portString; yield [namespace, endpoint, port, server]; } } } } // Set up a watch for services // Since the watch API we have _doesn't_ notify us when things have // changed, we'll need to do some trickery and wrap the underlying watcher // with our own code. class WrappedWatch extends k8s.Watch { callback: () => void; constructor(kubeconfig: k8s.KubeConfig, callback: () => void) { super(kubeconfig); this.callback = callback; } watch( path: string, queryParams: any, callback: (phase: string, apiObj: any, watchObj?: any) => void, done: (err: any) => void, ): Promise { const wrappedCallback = (phase: string, apiObj: any, watchObj?: any) => { callback(phase, apiObj, watchObj); this.callback(); }; return super.watch(path, queryParams, wrappedCallback, done); } } /** A single port in a service returned by KubeClient.listServices() */ export interface ServiceEntry { /** The namespace the service is within. */ namespace?: string; /** The name of the service. */ name: string; /** The name of the port within the service. */ portName?: string; /** The internal port number (or name) of the service. */ port: number | string; /** The forwarded port on localhost (on the host), if any. */ listenPort?: number; } /** * KubeClient is a Kubernetes client that will _only_ manage the cluster we spin * up internally. The user should call initialize() once the cluster has been * created. */ export class KubeClient extends events.EventEmitter { protected kubeconfig = new k8s.KubeConfig(); protected forwarder: k8s.PortForward; protected shutdown = false; /** * Kubernetes services across all namespaces. */ protected services: k8s.ListWatch | null; /** * Active port forwarding servers. This records the desired state: if an * entry exists, then we want to set up port forwarding for it. */ protected servers = new ForwardingMap(); /** * Collection of active sockets. Used to clean up connections when attempting * to close a server. Keys can be any string, but are formatted as * namespace/endpoint:port to help match sockets to the corresponding server. */ protected sockets = new Map(); protected coreV1API: k8s.CoreV1Api; /** * initialize the KubeClient so that we are ready to talk to it. */ constructor() { super(); this.kubeconfig.loadFromDefault(); this.kubeconfig.currentContext = 'rancher-desktop'; this.forwarder = new k8s.PortForward(this.kubeconfig, true); this.shutdown = false; this.coreV1API = this.kubeconfig.makeApiClient(k8s.CoreV1Api); this.services = null; } get k8sClient() { return this.kubeconfig; } // This functionality was originally in the constructor, but in order to // avoid the complexity of async constructors, extract it out into an // async method. async waitForServiceWatcher() { const startTime = Date.now(); const maxWaitTime = 300_000; const waitTime = 3_000; while (true) { const currentTime = Date.now(); if ((currentTime - startTime) > maxWaitTime) { console.log(`Waited more than ${ maxWaitTime / 1000 } secs for kubernetes to fully start up. Giving up.`); break; } if (await this.getServiceListWatch()) { break; } await util.promisify(setTimeout)(waitTime); } } /** * Get the service watcher, ensuring that it's actually ready to react to * changes in the services. */ async getServiceListWatch() { if (this.services) { return this.services; } // If this API call reports that there are zero services currently running, // return null (and it's up to the caller to retry later). // This doesn't make complete sense, because if we've reached this point, // the k3s server must be running. But with wsl we've observed that the service // watcher needs more time to start up. When this call returns at least one // service, it's ready. try { const { items } = await this.coreV1API.listServiceForAllNamespaces(); if (!(items.length > 0)) { return null; } } catch (ex) { console.debug(`Error fetching services: ${ ex }`); return null; } this.services = new k8s.ListWatch( '/api/v1/services', new WrappedWatch(this.kubeconfig, () => { this.emit('service-changed', this.listServices()); }), () => this.coreV1API.listServiceForAllNamespaces()); return this.services; } /** * Wait for at least one node in the cluster to become ready. This is taken * as an indication that the cluster is ready to be used. */ async waitForReadyNodes(): Promise { while (true) { const { items } = await this.coreV1API.listNode(); const conditions = items.flatMap(node => node.status?.conditions ?? []); const ready = conditions.some(condition => condition.type === 'Ready' && condition.status === 'True'); if (ready) { return; } await util.promisify(setTimeout)(1_000); } } // Notify the client that the underlying Kubernetes cluster is about to go // away, and we should remove any pending work. destroy() { this.shutdown = true; for (const [namespace, endpoint, port, server] of this.servers) { this.servers.delete(namespace, endpoint, port); server?.close(); } this.removeAllListeners('service-changed'); } protected async getEndpointSubsets(namespace: string, endpointName: string): Promise { console.log(`Attempting to locate endpoint subsets ${ endpointName }...`); // Loop fetching endpoints, until it matches at least one subset. let target: k8s.V1EndpointSubset[] | undefined; // TODO: switch this to using watch. while (!this.shutdown) { const endpoints = await this.coreV1API.listNamespacedEndpoints({ namespace, fieldSelector: `metadata.name==${ endpointName }`, }); const items = endpoints.items.filter(item => item.metadata?.name === endpointName); target = items.flatMap(item => item.subsets).filter(defined); if (target.length > 0 || this.shutdown) { break; } console.log(`Could not find ${ endpointName } endpoint (${ endpoints ? 'did' : 'did not' } get endpoints), retrying...`); await util.promisify(setTimeout)(1000); } return target ?? null; } protected async getActivePodFromEndpointSubsets(subsets: k8s.V1EndpointSubset[]) { const addresses = subsets.flatMap(subset => subset.addresses).filter(defined); const address = addresses.find(address => address.targetRef?.kind === 'Pod'); const target = address?.targetRef; if (!target?.name || !target.namespace) { return null; } // Fetch the pod try { return await this.coreV1API.readNamespacedPod({ name: target.name, namespace: target.namespace, }); } catch (ex) { if (ex instanceof k8s.ApiException && ex.code === 404) { return null; } throw ex; } } /** * Return a pod that is part of a given endpoint and ready to receive traffic. * @param namespace The namespace in which to look for resources. * @param endpointName the name of an endpoint that controls ready pods. */ async getActivePod(namespace: string, endpointName: string): Promise { console.log(`Attempting to locate ${ endpointName } pod...`); while (!this.shutdown) { const subsets = await this.getEndpointSubsets(namespace, endpointName); if (!subsets) { await util.promisify(setTimeout)(1000); continue; } const pod = await this.getActivePodFromEndpointSubsets(subsets); if (!pod) { await util.promisify(setTimeout)(1000); continue; } console.log(`Got ${ endpointName } pod: ${ pod.metadata?.namespace }:${ pod.metadata?.name }`); return pod; } return null; } /** * Formats the namespace, endpoint, and port as namespace/endpoint:port * @param namespace The namespace to forward to. * @param endpoint The endpoint in the namespace to forward to. * @param port The port to forward to on the endpoint. * @returns A formatted string consisting of the namespace/endpoint:port */ private targetName = (namespace: string, endpoint: string, port: number | string) => `${ namespace }/${ endpoint }:${ port }`; /** * Given a Pod object, returns its namespace, its name and the port number matching * the passed port name/number. * @param pod The pod to extract the info from. * @param k8sPort The port name or number to get the port number from. * @returns An array containing the pod namespace, the pod name and the port number. */ protected getPodDetails(pod: k8s.V1Pod, k8sPort: number | string): [string, string, number] { if (!pod.metadata) { throw new Error('Pod has no metadata'); } if (!pod.metadata.name) { throw new Error('Pod has no name'); } const podNamespace = pod.metadata.namespace ?? 'default'; const podName = pod.metadata.name; let portNumber: number; if (typeof k8sPort === 'number') { portNumber = k8sPort; } else { if (!pod.spec) { throw new Error(`Pod "${ podName } does not have a spec property`); } const podPorts = pod.spec.containers.flatMap(container => container.ports); const podPort = podPorts.find(port => port?.name === k8sPort); if (!podPort) { throw new Error(`Could not find port number for pod "${ podName }`); } portNumber = podPort.containerPort; } return [podNamespace, podName, portNumber]; } /** * Forward a port to a kubernetes service. The port forwarding will not work * until the endpoint is ready. * @param namespace The namespace to forward to. * @param endpoint The endpoint in the namespace to forward to. * @param k8sPort The port to forward to on the endpoint. * @param hostPort The host port to listen on for the forwarded port. Pass 0 for a random port. */ protected async createForwardingServer(namespace: string, endpoint: string, k8sPort: number | string, hostPort: number): Promise { const targetName = this.targetName(namespace, endpoint, k8sPort); const server = net.createServer(async(socket) => { // We need some helpers to convince TypeScript that our errors have // `code: string` and `error: Error` properties. interface ErrorWithStringCode extends Error { code: string } interface ErrorWithNestedError extends Error { error: Error } const isError = (error: Error, prop: string): error is T => { return prop in error; }; socket.on('error', (error) => { // Handle the error, so that we don't get an ugly dialog about it. const code = isError(error, 'code') ? error.code : 'MISSING'; const innerError = isError(error, 'error') ? error.error : error; console.error(`Error creating proxy for ${ targetName }: code "${ code }" error "${ innerError }"`); }); // add socket to this.sockets so it can be cleaned up this.sockets.set(targetName, [...this.sockets.get(targetName) || [], socket]); // get the details of the pod we are forwarding to const endpoints = await this.getEndpointSubsets(namespace, endpoint) ?? []; console.debug(`Got endpoints subsets: ${ JSON.stringify(endpoints) }`); const pod = await this.getActivePodFromEndpointSubsets(endpoints); console.debug(`Got active pod: ${ JSON.stringify(pod) }`); if (!pod) { throw new Error(`No active pod found`); } const [podNamespace, podName, portNumber] = this.getPodDetails(pod, k8sPort); console.debug(`Got podNamespace = "${ podNamespace }"`); console.debug(`Got podName = "${ podName }"`); console.debug(`Got portNumber = "${ portNumber }"`); // check if server is still valid if (!this.servers.has(namespace, endpoint, k8sPort)) { throw new Error('Server is no longer valid'); } // forward the port const stdin = new ErrorSuppressingStdin(socket); this.forwarder.portForward(podNamespace, podName, [portNumber], socket, null, stdin) .catch((e) => { console.log(`Failed to create web socket for forwarding to ${ targetName }: ${ e?.error || e }`); socket.destroy(e); }); }); // Start listening, and block until the listener has been established. await new Promise((resolve, reject) => { const cleanup = () => { resolve = reject = () => { }; server.off('listening', resolveOnce); server.off('error', rejectOnce); }; const resolveOnce = () => { resolve(undefined); cleanup(); }; const rejectOnce = (error?: any) => { reject(error); cleanup(); }; server.once('close', () => { rejectOnce(new Error('Server closed')); }); server.once('listening', resolveOnce); server.once('error', rejectOnce); server.listen({ port: hostPort, host: '127.0.0.1' }); }); return server; } /** * Create a port forward for an endpoint, listening on localhost. * @param namespace The namespace containing the end points to forward to. * @param endpoint The endpoint to forward to. * @param k8sPort The port to forward to on the endpoint. * @param hostPort The host port to listen on for the forwarded port. Pass 0 for a random port. * @return The port number for the port forward. */ async forwardPort(namespace: string, endpoint: string, k8sPort: number | string, hostPort: number): Promise { const targetName = this.targetName(namespace, endpoint, k8sPort); let server = this.servers.get(namespace, endpoint, k8sPort); if (server) { console.log(`Found existing server for ${ targetName }.`); const currentHostPort = (server.address() as net.AddressInfo).port; if (currentHostPort === hostPort) { console.log(`Server listening on ${ hostPort }, which is what we want.`); return hostPort; } else { console.log(`Server listening on ${ currentHostPort }, but we want ${ hostPort }. Closing it.`); await this.closeServerAndConns(namespace, endpoint, k8sPort); } } // create server console.log(`Setting up new port forwarding to ${ targetName }...`); try { server = await this.createForwardingServer(namespace, endpoint, k8sPort, hostPort); } catch (error: any) { console.error(error); let errorMessage = ''; if (error.code === 'ERR_SOCKET_BAD_PORT') { errorMessage = `"${ hostPort }" is not a valid port.`; } else if (error.code === 'EADDRINUSE') { errorMessage = `Port ${ hostPort } is already in use.`; } else if (error.code === 'EACCES') { errorMessage = `You do not have permission to use port ${ hostPort }.`; } if (errorMessage) { const serviceEntry: ServiceEntry = { namespace, name: endpoint, port: k8sPort, listenPort: hostPort, }; this.emit('service-error', serviceEntry, errorMessage); return; } throw error; } console.log(`Forwarding server for ${ targetName } created.`); // add it to this.servers if value for targetName hasn't been filled in meantime if (!this.servers.get(namespace, endpoint, k8sPort)) { this.servers.set(namespace, endpoint, k8sPort, server); console.log(`Forwarding server for ${ targetName } added to server list.`); } else { console.warn(`Another forwarding server for ${ targetName } was found; closing this one.`); server.close(); } // Trigger a UI refresh this.emit('service-changed', this.listServices()); const address = server.address() as net.AddressInfo; return address.port; } /** * Ensure that the forwarding server for a given combination of arguments is closed, * and that all connections related to it are destroyed. * @param namespace The namespace to forward to. * @param endpoint The endpoint in the namespace to forward to. * @param k8sPort The port to forward to on the endpoint. */ protected async closeServerAndConns(namespace: string, endpoint: string, k8sPort: number | string): Promise { const targetName = this.targetName(namespace, endpoint, k8sPort); const server = this.servers.get(namespace, endpoint, k8sPort); // close and remove sockets for this server this.sockets.get(targetName)?.forEach(socket => socket.destroy()); this.sockets.delete(targetName); // close server this.servers.delete(namespace, endpoint, k8sPort); if (server) { await new Promise((resolve) => { server.close(resolve); }); } } /** * Ensure that a given port forwarding does not exist; if it does, close it. * @param namespace The namespace to forward to. * @param endpoint The endpoint in the namespace to forward to. * @param k8sPort The port to forward to on the endpoint. */ async cancelForwardPort(namespace: string, endpoint: string, k8sPort: number | string): Promise { await this.closeServerAndConns(namespace, endpoint, k8sPort); this.emit('service-changed', this.listServices()); } /** * Get the cached list of services. * @param namespace The namespace to limit fetches to. * @returns The services currently in the system. */ listServices(namespace: string | undefined = undefined): ServiceEntry[] { if (!this.services) { return []; } return this.services.list(namespace)?.flatMap((service) => { return (service.spec?.ports || []).map((port) => { const namespace = service.metadata?.namespace; const name = service.metadata?.name || ''; const portNumber = port.targetPort as unknown as number; const server = this.servers.get(namespace, name, portNumber); const address = server?.address(); const listenPort = address !== undefined ? (address as net.AddressInfo).port : undefined; return { namespace, name, portName: port.name, port: portNumber, listenPort, }; }); }); } } ================================================ FILE: pkg/rancher-desktop/backend/kube/lima.ts ================================================ import events from 'events'; import fs from 'fs'; import os from 'os'; import path from 'path'; import timers from 'timers'; import util from 'util'; import semver from 'semver'; import { Architecture, BackendSettings, RestartReasons } from '../backend'; import BackendHelper, { MANIFEST_CERT_MANAGER, MANIFEST_SPIN_OPERATOR } from '../backendHelper'; import K3sHelper, { ExtraRequiresReasons, NoCachedK3sVersionsError, ShortVersion } from '../k3sHelper'; import LimaBackend, { Action } from '../lima'; import INSTALL_K3S_SCRIPT from '@pkg/assets/scripts/install-k3s'; import LOGROTATE_K3S_SCRIPT from '@pkg/assets/scripts/logrotate-k3s'; import SERVICE_CRI_DOCKERD_SCRIPT from '@pkg/assets/scripts/service-cri-dockerd.initd'; import SERVICE_K3S_SCRIPT from '@pkg/assets/scripts/service-k3s.initd'; import * as K8s from '@pkg/backend/k8s'; import { KubeClient } from '@pkg/backend/kube/client'; import { LockedFieldError } from '@pkg/config/commandLineOptions'; import { ContainerEngine } from '@pkg/config/settings'; import mainEvents from '@pkg/main/mainEvents'; import { checkConnectivity } from '@pkg/main/networking'; import clone from '@pkg/utils/clone'; import { SemanticVersionEntry } from '@pkg/utils/kubeVersions'; import Logging from '@pkg/utils/logging'; import paths from '@pkg/utils/paths'; import { RecursivePartial } from '@pkg/utils/typeUtils'; import { showMessageBox } from '@pkg/window'; const console = Logging.kube; export default class LimaKubernetesBackend extends events.EventEmitter implements K8s.KubernetesBackend { constructor(arch: Architecture, vm: LimaBackend) { super(); this.arch = arch; this.vm = vm; this.k3sHelper = new K3sHelper(arch); this.k3sHelper.on('versions-updated', () => this.emit('versions-updated')); this.k3sHelper.initialize().catch((err) => { console.log('k3sHelper.initialize failed: ', err); // If we fail to initialize, we still need to continue (with no versions). this.emit('versions-updated'); }); mainEvents.on('network-ready', () => this.k3sHelper.networkReady()); } /** * Download K3s images. This will also calculate the version to download. * @precondition The VM must be running. * @returns The version of K3s images downloaded, and whether this is a * downgrade. */ async download(cfg: BackendSettings): Promise<[semver.SemVer | undefined, boolean]> { this.cfg = cfg; const interval = timers.setInterval(() => { const statuses = [ this.k3sHelper.progress.checksum, this.k3sHelper.progress.exe, this.k3sHelper.progress.images, ]; const sum = (key: 'current' | 'max') => { return statuses.reduce((v, c) => v + c[key], 0); }; const current = sum('current'); const max = sum('max'); this.progressTracker.numeric('Downloading Kubernetes components', current, max); }); try { const persistedVersion = await K3sHelper.getInstalledK3sVersion(this.vm); const desiredVersion = await this.desiredVersion; if (desiredVersion === undefined) { // If we could not determine the desired version (e.g. we have no cached // versions and the machine is offline), bail out. return [undefined, false]; } const isDowngrade = (version: semver.SemVer | string) => { return !!persistedVersion && semver.gt(persistedVersion, version); }; console.debug(`Download: desired=${ desiredVersion } persisted=${ persistedVersion }`); try { await this.progressTracker.action('Checking k3s images', 100, this.k3sHelper.ensureK3sImages(desiredVersion)); return [desiredVersion, isDowngrade(desiredVersion)]; } catch (ex) { if (!await checkConnectivity('github.com')) { throw ex; } try { const newVersion = await K3sHelper.selectClosestImage(desiredVersion); // Show a warning if we are downgrading from the desired version, but // only if it's not already a downgrade (where the user had already // accepted it). if (desiredVersion.compare(newVersion) > 0 && !isDowngrade(desiredVersion)) { const options: Electron.MessageBoxOptions = { message: `Downgrading from ${ desiredVersion.raw } to ${ newVersion.raw } will lose existing Kubernetes workloads. Delete the data?`, type: 'question', buttons: ['Delete Workloads', 'Cancel'], defaultId: 1, title: 'Confirming migration', cancelId: 1, }; const result = await showMessageBox(options, true); if (result.response !== 0) { return [undefined, true]; } } console.log(`Going with alternative version ${ newVersion.raw }`); return [newVersion, isDowngrade(newVersion)]; } catch (ex: any) { if (ex instanceof NoCachedK3sVersionsError) { throw new K8s.KubernetesError('No version available', 'The k3s cache is empty and there is no network connection.'); } throw ex; } } } finally { timers.clearInterval(interval); } } /** * Install the Kubernetes files. */ async install(config: BackendSettings, desiredVersion: semver.SemVer, allowSudo: boolean) { await this.progressTracker.action('Installing k3s', 50, async() => { const promises: Promise[] = []; promises.push(this.writeServiceScript(config, desiredVersion, allowSudo)); promises.push((async() => { // installK3s removes old config and makes sure the directories are recreated; // this means it must be done before adding custom manifests. await this.installK3s(desiredVersion); const localPromises: Promise[] = []; if (config.experimental?.containerEngine?.webAssembly?.enabled) { localPromises.push(BackendHelper.configureRuntimeClasses(this.vm)); if (config.experimental?.kubernetes?.options?.spinkube) { localPromises.push(BackendHelper.configureSpinOperator(this.vm)); } } await Promise.all(localPromises); })()); await Promise.all(promises); }); this.activeVersion = desiredVersion; } /** * Start Kubernetes. * @returns The Kubernetes endpoint */ async start(config_: BackendSettings, kubernetesVersion: semver.SemVer, kubeClient?: () => KubeClient): Promise { const config = this.cfg = clone(config_); // Remove flannel config if necessary, before starting k3s if (!config.kubernetes.options.flannel) { await this.vm.execCommand({ root: true }, 'rm', '-f', '/etc/cni/net.d/10-flannel.conflist'); } await this.progressTracker.action('Starting k3s', 100, async() => { // Run rc-update as we have dynamic dependencies. await this.vm.execCommand({ root: true }, '/sbin/rc-update', '--update'); await this.vm.execCommand({ root: true }, '/sbin/rc-service', '--ifnotstarted', 'k3s', 'start'); }); const aborted = await this.progressTracker.action( 'Waiting for Kubernetes API', 100, async() => { await this.k3sHelper.waitForServerReady(() => Promise.resolve('127.0.0.1'), config.kubernetes.port); while (true) { if (this.vm.currentAction !== Action.STARTING) { // User aborted return true; } try { await this.vm.execCommand({ expectFailure: true }, 'ls', '/etc/rancher/k3s/k3s.yaml'); break; } catch (ex) { console.log('Configuration /etc/rancher/k3s/k3s.yaml not present in lima vm; will check again...'); await util.promisify(setTimeout)(1_000); } } console.debug('/etc/rancher/k3s/k3s.yaml is ready.'); return false; }, ); if (aborted) { return; } await this.progressTracker.action( 'Updating kubeconfig', 50, this.k3sHelper.updateKubeconfig( () => this.vm.execCommand({ capture: true, root: true }, 'cat', '/etc/rancher/k3s/k3s.yaml'), )); const client = this.client = kubeClient?.() || new KubeClient(); await this.progressTracker.action( 'Waiting for services', 50, async() => { await client.waitForServiceWatcher(); client.on('service-changed', (services) => { this.emit('service-changed', services); }); client.on('service-error', (service, errorMessage) => { this.emit('service-error', service, errorMessage); }); }, ); this.activeVersion = kubernetesVersion; this.currentPort = config.kubernetes.port; this.emit('current-port-changed', this.currentPort); // Remove traefik if necessary. if (!this.cfg?.kubernetes?.options.traefik) { await this.progressTracker.action( 'Removing Traefik', 50, this.k3sHelper.uninstallHelmChart(client, 'traefik')); } if (!this.cfg?.experimental?.kubernetes?.options?.spinkube) { await this.progressTracker.action( 'Removing spinkube operator', 50, Promise.all([ this.k3sHelper.uninstallHelmChart(client, MANIFEST_CERT_MANAGER), this.k3sHelper.uninstallHelmChart(client, MANIFEST_SPIN_OPERATOR), ])); } await this.progressTracker.action('Ensuring compatible kubectl', 50, this.k3sHelper.getCompatibleKubectlVersion(this.activeVersion)); if (this.cfg?.kubernetes?.options.flannel) { await this.progressTracker.action( 'Waiting for nodes', 100, client.waitForReadyNodes()); } else { await this.progressTracker.action( 'Skipping node checks, flannel is disabled', 100, async() => { await new Promise(resolve => setTimeout(resolve, 5000)); }); } } async stop() { if (this.cfg?.kubernetes?.enabled) { try { const script = 'if [ -e /etc/init.d/k3s ]; then /sbin/rc-service --ifstarted k3s stop; fi'; await this.vm.execCommand({ root: true, expectFailure: true }, '/bin/sh', '-c', script); } catch (ex) { console.error('Failed to stop k3s while stopping kube backend: ', ex); } } await this.cleanup(); } cleanup(): Promise { this.client?.destroy(); return Promise.resolve(); } async reset() { await this.k3sHelper.deleteKubeState(this.vm); } cfg: BackendSettings | undefined; protected readonly arch: Architecture; protected readonly vm: LimaBackend; protected activeVersion?: semver.SemVer; /** The port Kubernetes is actively listening on. */ protected currentPort = 0; /** Helper object to manage available K3s versions. */ readonly k3sHelper: K3sHelper; protected client: KubeClient | null = null; protected get progressTracker() { return this.vm.progressTracker; } get version(): ShortVersion { return this.activeVersion?.version ?? ''; } get availableVersions(): Promise { return this.k3sHelper.availableVersions; } async cachedVersionsOnly(): Promise { return await K3sHelper.cachedVersionsOnly(); } get desiredPort() { return this.cfg?.kubernetes?.port ?? 6443; } protected get desiredVersion(): Promise { return (async() => { let availableVersions: SemanticVersionEntry[]; let available = true; try { availableVersions = await this.k3sHelper.availableVersions; return await BackendHelper.getDesiredVersion( this.cfg!, availableVersions, this.vm.noModalDialogs, this.vm.writeSetting.bind(this.vm)); } catch (ex) { // Locked field errors are fatal and will quit the application if (ex instanceof LockedFieldError) { throw ex; } console.error(`Could not get desired version: ${ ex }`); available = false; return undefined; } finally { mainEvents.emit('diagnostics-event', { id: 'kube-versions-available', available }); } })(); } /** * Install K3s into the VM for execution. * @param version The version to install. */ protected async installK3s(version: semver.SemVer) { const k3s = this.arch === 'aarch64' ? 'k3s-arm64' : 'k3s'; await this.vm.execCommand('mkdir', '-p', 'bin'); await this.vm.writeFile('bin/install-k3s', INSTALL_K3S_SCRIPT, 'a+x'); await fs.promises.chmod(path.join(paths.cache, 'k3s', version.raw, k3s), 0o755); await this.vm.execCommand({ root: true }, 'bin/install-k3s', version.raw, path.join(paths.cache, 'k3s')); } /** * Write the openrc script for k3s. */ protected async writeServiceScript(cfg: BackendSettings, desiredVersion: semver.SemVer, allowSudo: boolean) { const allPlatformsThresholdVersion = '1.31.0'; const config: Record = { PORT: this.desiredPort.toString(), ENGINE: cfg.containerEngine.name ?? ContainerEngine.NONE, ADDITIONAL_ARGS: `--node-ip ${ await this.vm.ipAddress }`, LOG_DIR: paths.logs, USE_CRI_DOCKERD: BackendHelper.requiresCRIDockerd(cfg.containerEngine.name, desiredVersion.version).toString(), ALLPLATFORMS: semver.lt(desiredVersion, allPlatformsThresholdVersion) ? '--all-platforms' : '', }; if (os.platform() === 'darwin') { if (cfg.kubernetes.options.flannel) { const { iface, addr } = await this.vm.getListeningInterface(allowSudo); config.ADDITIONAL_ARGS += ` --flannel-iface ${ iface }`; if (addr) { config.ADDITIONAL_ARGS += ` --node-external-ip ${ addr }`; } } else { console.log(`Disabling flannel and network policy`); config.ADDITIONAL_ARGS += ' --flannel-backend=none --disable-network-policy'; } } if (!cfg.kubernetes.options.traefik) { config.ADDITIONAL_ARGS += ' --disable traefik'; } if (cfg.application.debug) { config.ADDITIONAL_ARGS += ' --debug'; } await this.vm.writeFile('/etc/init.d/cri-dockerd', SERVICE_CRI_DOCKERD_SCRIPT, 0o755); await this.vm.writeConf('cri-dockerd', { LOG_DIR: paths.logs, ENGINE: cfg.containerEngine.name ?? ContainerEngine.NONE, }); await this.vm.writeFile('/etc/init.d/k3s', SERVICE_K3S_SCRIPT, 0o755); await this.vm.writeConf('k3s', config); await this.vm.writeFile('/etc/logrotate.d/k3s', LOGROTATE_K3S_SCRIPT); } async deleteIncompatibleData(desiredVersion: semver.SemVer) { const existingVersion = await K3sHelper.getInstalledK3sVersion(this.vm); if (!existingVersion) { return; } if (semver.gt(existingVersion, desiredVersion)) { await this.progressTracker.action( 'Deleting incompatible Kubernetes state', 100, this.k3sHelper.deleteKubeState(this.vm)); } } async requiresRestartReasons(currentConfig: BackendSettings, desiredConfig: RecursivePartial, extra: ExtraRequiresReasons): Promise { // This is a placeholder to force this method to be async await Promise.all([]); return this.k3sHelper.requiresRestartReasons( currentConfig, desiredConfig, { 'application.adminAccess': undefined, }, extra, ); } listServices(namespace?: string): K8s.ServiceEntry[] { return this.client?.listServices(namespace) || []; } async forwardPort(namespace: string, service: string, k8sPort: number | string, hostPort: number): Promise { return await this.client?.forwardPort(namespace, service, k8sPort, hostPort); } async cancelForward(namespace: string, service: string, k8sPort: number | string): Promise { await this.client?.cancelForwardPort(namespace, service, k8sPort); } // #region Events eventNames(): (keyof K8s.KubernetesBackendEvents)[] { return super.eventNames() as (keyof K8s.KubernetesBackendEvents)[]; } listeners( event: eventName, ): K8s.KubernetesBackendEvents[eventName][] { return super.listeners(event) as K8s.KubernetesBackendEvents[eventName][]; } rawListeners( event: eventName, ): K8s.KubernetesBackendEvents[eventName][] { return super.rawListeners(event) as K8s.KubernetesBackendEvents[eventName][]; } // #endregion } ================================================ FILE: pkg/rancher-desktop/backend/kube/wsl.ts ================================================ import events from 'events'; import path from 'path'; import timers from 'timers'; import util from 'util'; import semver from 'semver'; import { KubeClient } from './client'; import K3sHelper, { ExtraRequiresReasons, NoCachedK3sVersionsError, ShortVersion } from '../k3sHelper'; import WSLBackend, { Action } from '../wsl'; import INSTALL_K3S_SCRIPT from '@pkg/assets/scripts/install-k3s'; import { BackendSettings, RestartReasons } from '@pkg/backend/backend'; import BackendHelper, { MANIFEST_CERT_MANAGER, MANIFEST_SPIN_OPERATOR } from '@pkg/backend/backendHelper'; import * as K8s from '@pkg/backend/k8s'; import { LockedFieldError } from '@pkg/config/commandLineOptions'; import { ContainerEngine } from '@pkg/config/settings'; import mainEvents from '@pkg/main/mainEvents'; import { checkConnectivity } from '@pkg/main/networking'; import { SemanticVersionEntry } from '@pkg/utils/kubeVersions'; import Logging from '@pkg/utils/logging'; import paths from '@pkg/utils/paths'; import { RecursivePartial } from '@pkg/utils/typeUtils'; import { showMessageBox } from '@pkg/window'; const console = Logging.kube; export default class WSLKubernetesBackend extends events.EventEmitter implements K8s.KubernetesBackend { constructor(vm: WSLBackend) { super(); this.vm = vm; this.k3sHelper.on('versions-updated', () => this.emit('versions-updated')); this.k3sHelper.initialize().catch((err) => { console.log('k3sHelper.initialize failed: ', err); // If we fail to initialize, we still need to continue (with no versions). this.emit('versions-updated'); }); mainEvents.on('network-ready', () => this.k3sHelper.networkReady()); } protected cfg: BackendSettings | undefined; protected vm: WSLBackend; /** Helper object to manage available K3s versions. */ readonly k3sHelper = new K3sHelper('x86_64'); protected client: KubeClient | null = null; /** The version of Kubernetes currently running. */ protected activeVersion: semver.SemVer | undefined; /** The port the Kubernetes server is listening on (default 6443) */ protected currentPort = 0; get progressTracker() { return this.vm.progressTracker; } protected get downloadURL() { return 'https://github.com/k3s-io/k3s/releases/download'; } get version(): ShortVersion { return this.activeVersion?.version ?? ''; } get port(): number { return this.currentPort; } get availableVersions(): Promise { return this.k3sHelper.availableVersions; } async cachedVersionsOnly(): Promise { return await K3sHelper.cachedVersionsOnly(); } protected get desiredVersion(): Promise { return (async() => { let availableVersions: SemanticVersionEntry[]; let available = true; try { availableVersions = await this.k3sHelper.availableVersions; return await BackendHelper.getDesiredVersion( this.cfg!, availableVersions, this.vm.noModalDialogs, this.vm.writeSetting.bind(this.vm)); } catch (ex) { // Locked field errors are fatal and will quit the application if (ex instanceof LockedFieldError) { throw ex; } console.error(`Could not get desired version: ${ ex }`); available = false; return undefined; } finally { mainEvents.emit('diagnostics-event', { id: 'kube-versions-available', available }); } })(); } async deleteIncompatibleData(desiredVersion: semver.SemVer) { const existingVersion = await K3sHelper.getInstalledK3sVersion(this.vm); if (!existingVersion) { return; } if (semver.gt(existingVersion, desiredVersion)) { console.log(`Deleting incompatible Kubernetes state due to downgrade from ${ existingVersion } to ${ desiredVersion }...`); await this.vm.progressTracker.action( 'Deleting incompatible Kubernetes state', 100, this.k3sHelper.deleteKubeState(this.vm)); } } get desiredPort() { return this.cfg?.kubernetes?.port ?? 6443; } /** * Download K3s images. This will also calculate the version to download. * @returns The version of K3s images downloaded, and whether this is a * downgrade. */ async download(cfg: BackendSettings): Promise<[semver.SemVer | undefined, boolean]> { this.cfg = cfg; const interval = timers.setInterval(() => { const statuses = [ this.k3sHelper.progress.checksum, this.k3sHelper.progress.exe, this.k3sHelper.progress.images, ]; const sum = (key: 'current' | 'max') => { return statuses.reduce((v, c) => v + c[key], 0); }; const current = sum('current'); const max = sum('max'); this.progressTracker.numeric('Downloading Kubernetes components', current, max); }); try { const desiredVersion = await this.desiredVersion; if (desiredVersion === undefined) { return [undefined, false]; } try { await this.progressTracker.action('Checking k3s images', 100, this.k3sHelper.ensureK3sImages(desiredVersion)); return [desiredVersion, false]; } catch (ex) { if (!await checkConnectivity('github.com')) { throw ex; } try { const newVersion = await K3sHelper.selectClosestImage(desiredVersion); const isDowngrade = semver.lt(newVersion, desiredVersion); if (isDowngrade) { const options: Electron.MessageBoxOptions = { message: `Downgrading from ${ desiredVersion.raw } to ${ newVersion.raw } will lose existing Kubernetes workloads. Delete the data?`, type: 'question', buttons: ['Delete Workloads', 'Cancel'], defaultId: 1, title: 'Confirming migration', cancelId: 1, }; const result = await showMessageBox(options, true); if (result.response !== 0) { return [undefined, true]; } } console.log(`Going with alternative version ${ newVersion.raw }`); return [newVersion, isDowngrade]; } catch (ex: any) { if (ex instanceof NoCachedK3sVersionsError) { throw new K8s.KubernetesError('No version available', 'The k3s cache is empty and there is no network connection.'); } throw ex; } } } finally { timers.clearInterval(interval); } } async install(config: BackendSettings, version: semver.SemVer, allowSudo: boolean) { await this.vm.runInstallScript(INSTALL_K3S_SCRIPT, 'install-k3s', version.raw, await this.vm.wslify(path.join(paths.cache, 'k3s'))); if (config.experimental?.containerEngine?.webAssembly?.enabled) { const promises: Promise[] = []; promises.push(BackendHelper.configureRuntimeClasses(this.vm)); if (config.experimental?.kubernetes?.options?.spinkube) { promises.push(BackendHelper.configureSpinOperator(this.vm)); } await Promise.all(promises); } } async start(config: BackendSettings, activeVersion: semver.SemVer, kubeClient?: () => KubeClient): Promise { if (!config) { throw new Error('no config!'); } this.cfg = config; // Clean up kubernetes cgroups before we start, as Kubernetes 1.31.0+ fails // to start if these are left over. We need to remove all cgroups named // "kubepods" as well as their descendants (which are expected to all be // empty). await this.progressTracker.action('Removing stale state', 50, this.vm.execCommand('busybox', 'find', '/sys/fs/cgroup', '-name', 'kubepods', '-exec', 'busybox', 'find', '{}', '-type', 'd', '-delete', ';', '-prune')); const executable = config.containerEngine.name === ContainerEngine.MOBY ? 'docker' : 'nerdctl'; await this.vm.verifyReady(executable, 'images'); // Remove flannel config if necessary, before starting k3s if (!config.kubernetes.options.flannel) { await this.vm.execCommand('busybox', 'rm', '-f', '/etc/cni/net.d/10-flannel.conflist'); } await this.progressTracker.action('Starting k3s', 100, this.vm.startService('k3s')); if (this.vm.currentAction !== Action.STARTING) { // User aborted return; } await this.progressTracker.action( 'Updating kubeconfig', 100, async() => { // Wait for the file to exist first, for slow machines. const command = 'if test -r /etc/rancher/k3s/k3s.yaml; then echo yes; else echo no; fi'; while (true) { const result = await this.vm.execCommand({ capture: true }, '/bin/sh', '-c', command); if (result.includes('yes')) { break; } await util.promisify(timers.setTimeout)(1_000); } await this.k3sHelper.updateKubeconfig( async() => await this.vm.execCommand({ capture: true }, await this.vm.getWSLHelperPath(), 'k3s', 'kubeconfig')); }); await this.progressTracker.action( 'Waiting for Kubernetes API', 100, this.k3sHelper.waitForServerReady(() => this.vm.ipAddress, config.kubernetes?.port)); const client = this.client = kubeClient?.() || new KubeClient(); await this.progressTracker.action( 'Waiting for services', 50, async() => { await client.waitForServiceWatcher(); client.on('service-changed', (services) => { this.emit('service-changed', services); }); client.on('service-error', (service, errorMessage) => { this.emit('service-error', service, errorMessage); }); }); this.activeVersion = activeVersion; this.currentPort = config.kubernetes.port; this.emit('current-port-changed', this.currentPort); const tasks: Promise[] = [ this.k3sHelper.getCompatibleKubectlVersion(this.activeVersion), ]; // Remove traefik if necessary. if (!config.kubernetes.options.traefik) { tasks.push(this.progressTracker.action( 'Removing Traefik', 50, this.k3sHelper.uninstallHelmChart(client, 'traefik'))); } if (!this.cfg?.experimental?.kubernetes?.options?.spinkube) { tasks.push(this.progressTracker.action( 'Removing spinkube operator', 50, Promise.all([ this.k3sHelper.uninstallHelmChart(this.client, MANIFEST_CERT_MANAGER), this.k3sHelper.uninstallHelmChart(this.client, MANIFEST_SPIN_OPERATOR), ]))); } if (config.kubernetes.options.flannel) { tasks.push(this.progressTracker.action( 'Waiting for nodes', 100, client.waitForReadyNodes())); } await Promise.all(tasks); } async stop() { await this.cleanup(); // No need to actually stop the service; the whole distro will shut down. } cleanup() { this.client?.destroy(); return Promise.resolve(); } async reset() { await this.k3sHelper.deleteKubeState(this.vm); } requiresRestartReasons(oldConfig: BackendSettings, newConfig: RecursivePartial, extras: ExtraRequiresReasons = {}): Promise { return Promise.resolve(this.k3sHelper.requiresRestartReasons( oldConfig, newConfig, { 'kubernetes.ingress.localhostOnly': undefined, 'WSL.integrations': undefined, }, extras, )); } listServices(namespace?: string): K8s.ServiceEntry[] { return this.client?.listServices(namespace) || []; } async forwardPort(namespace: string, service: string, k8sPort: number | string, hostPort: number): Promise { return await this.client?.forwardPort(namespace, service, k8sPort, hostPort); } async cancelForward(namespace: string, service: string, k8sPort: number | string): Promise { await this.client?.cancelForwardPort(namespace, service, k8sPort); } // #region Events eventNames(): (keyof K8s.KubernetesBackendEvents)[] { return super.eventNames() as (keyof K8s.KubernetesBackendEvents)[]; } listeners( event: eventName, ): K8s.KubernetesBackendEvents[eventName][] { return super.listeners(event) as K8s.KubernetesBackendEvents[eventName][]; } rawListeners( event: eventName, ): K8s.KubernetesBackendEvents[eventName][] { return super.rawListeners(event) as K8s.KubernetesBackendEvents[eventName][]; } // #endregion } ================================================ FILE: pkg/rancher-desktop/backend/kubeconfig.ts ================================================ // kubernetes-client/javascript doesn't have support for the `proxy-url` cluster field. // We are providing variants of loadFromString() and exportConfig() that do. import childProcess, { spawn } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { findHomeDir, KubeConfig } from '@kubernetes/client-node'; import { ActionOnInvalid, ConfigOptions, exportContext, exportUser, newContexts, newUsers, } from '@kubernetes/client-node/dist/config_types'; import _ from 'lodash'; import yaml from 'yaml'; import { executable } from '@pkg/utils/resources'; interface Cluster { readonly name: string; readonly caData?: string; caFile?: string; readonly server: string; readonly skipTLSVerify: boolean; readonly tlsServerName?: string; readonly proxyUrl?: string; } export function loadFromString(kubeConfig : KubeConfig, config: string, opts?: Partial): void { const obj = yaml.parse(config); kubeConfig.clusters = newClusters(obj.clusters, opts); kubeConfig.contexts = newContexts(obj.contexts, opts); kubeConfig.users = newUsers(obj.users, opts); kubeConfig.currentContext = obj['current-context']; } function newClusters(a: any, opts?: Partial): Cluster[] { const options = Object.assign({ onInvalidEntry: ActionOnInvalid.THROW }, opts || {}); return _.compact(_.map(a, clusterIterator(options.onInvalidEntry))); } function exportCluster(cluster: Cluster): any { return { name: cluster.name, cluster: { server: cluster.server, 'certificate-authority-data': cluster.caData, 'certificate-authority': cluster.caFile, 'insecure-skip-tls-verify': cluster.skipTLSVerify, 'proxy-url': cluster.proxyUrl, 'tls-server-name': cluster.tlsServerName, }, }; } function clusterIterator(onInvalidEntry: ActionOnInvalid): _.ListIterator { return (elt: any, i: number, list: _.List): Cluster | null => { try { if (!elt.name) { throw new Error(`clusters[${ i }].name is missing`); } if (!elt.cluster) { throw new Error(`clusters[${ i }].cluster is missing`); } if (!elt.cluster.server) { throw new Error(`clusters[${ i }].cluster.server is missing`); } return { caData: elt.cluster['certificate-authority-data'], caFile: elt.cluster['certificate-authority'], name: elt.name, proxyUrl: elt.cluster['proxy-url'], server: elt.cluster.server.replace(/\/$/, ''), skipTLSVerify: elt.cluster['insecure-skip-tls-verify'] === true, tlsServerName: elt.cluster['tls-server-name'], }; } catch (err) { switch (onInvalidEntry) { case ActionOnInvalid.FILTER: return null; case ActionOnInvalid.THROW: default: throw err; } } }; } export function exportConfig(config : KubeConfig): string { const configObj = { apiVersion: 'v1', kind: 'Config', clusters: config.clusters.map(exportCluster), users: config.users.map(exportUser), contexts: config.contexts.map(exportContext), preferences: {}, 'current-context': config.getCurrentContext(), }; return JSON.stringify(configObj); } /** * Get the paths to the kubernetes client config path. * This is mainly useful for watching configuration changes. */ export async function getKubeConfigPaths(): Promise { async function hasAccess(filePath: string): Promise { try { await fs.promises.access(filePath, fs.constants.R_OK); } catch { return false; } return true; } if (process.env.KUBECONFIG && process.env.KUBECONFIG.length > 0) { const results: string[] = []; for (const filePath of process.env.KUBECONFIG.split(path.delimiter)) { if (await hasAccess(filePath)) { results.push(filePath); } } return results; } // We do not support locating kubeconfig files inside WSL distros, nor // in-cluster configs, so we only need to check the one path. return [path.join(findHomeDir() ?? os.homedir(), '.kube', 'config')]; } // The K8s JS library will get the current context but does not have the ability // to save the context. The current version of the package targets k8s 1.18 and // there are new config file features (e.g., proxy) that may be lost by outputting // the config with the library. So, we drop down to kubectl for this. export function setCurrentContext(ctx: string, exitfunc: (code: number | null, signal: NodeJS.Signals | null) => void) { const opts: childProcess.SpawnOptions = {}; opts.env = { ...process.env }; const bat = spawn(executable('kubectl'), ['config', 'use-context', ctx], opts); // TODO: For data toggle this based on a debug mode bat.stdout?.on('data', (data) => { console.log(data.toString()); }); bat.stderr?.on('data', (data) => { console.error(data.toString()); }); bat.on('exit', exitfunc); } ================================================ FILE: pkg/rancher-desktop/backend/lima.ts ================================================ // Kubernetes backend for macOS, based on Lima. import { ChildProcess, spawn as spawnWithSignal } from 'child_process'; import crypto from 'crypto'; import events from 'events'; import fs from 'fs'; import net from 'net'; import os from 'os'; import path from 'path'; import stream from 'stream'; import util from 'util'; import Electron from 'electron'; import merge from 'lodash/merge'; import omit from 'lodash/omit'; import semver from 'semver'; import tar from 'tar-stream'; import yaml from 'yaml'; import { Architecture, BackendError, BackendEvents, BackendProgress, BackendSettings, execOptions, FailureDetails, RestartReasons, State, VMBackend, VMExecutor, } from './backend'; import BackendHelper from './backendHelper'; import { ContainerEngineClient, MobyClient, NerdctlClient } from './containerClient'; import * as K8s from './k8s'; import ProgressTracker, { getProgressErrorDescription } from './progressTracker'; import DEPENDENCY_VERSIONS from '@pkg/assets/dependencies.yaml'; import DEFAULT_CONFIG from '@pkg/assets/lima-config.yaml'; import NETWORKS_CONFIG from '@pkg/assets/networks-config.yaml'; import FLANNEL_CONFLIST from '@pkg/assets/scripts/10-flannel.conflist'; import SERVICE_BUILDKITD_CONF from '@pkg/assets/scripts/buildkit.confd'; import SERVICE_BUILDKITD_INIT from '@pkg/assets/scripts/buildkit.initd'; import DOCKER_CREDENTIAL_SCRIPT from '@pkg/assets/scripts/docker-credential-rancher-desktop'; import LOGROTATE_LIMA_GUESTAGENT_SCRIPT from '@pkg/assets/scripts/logrotate-lima-guestagent'; import LOGROTATE_OPENRESTY_SCRIPT from '@pkg/assets/scripts/logrotate-openresty'; import NERDCTL from '@pkg/assets/scripts/nerdctl'; import NGINX_CONF from '@pkg/assets/scripts/nginx.conf'; import { ContainerEngine, MountType, VMType } from '@pkg/config/settings'; import { getServerCredentialsPath, ServerState } from '@pkg/main/credentialServer/httpCredentialHelperServer'; import mainEvents from '@pkg/main/mainEvents'; import { exec as sudo } from '@pkg/sudo-prompt'; import * as childProcess from '@pkg/utils/childProcess'; import clone from '@pkg/utils/clone'; import dockerDirManager from '@pkg/utils/dockerDirManager'; import Logging from '@pkg/utils/logging'; import paths from '@pkg/utils/paths'; import { executable } from '@pkg/utils/resources'; import { jsonStringifyWithWhiteSpace } from '@pkg/utils/stringify'; import { defined, RecursivePartial } from '@pkg/utils/typeUtils'; import { openSudoPrompt } from '@pkg/window'; /* eslint @typescript-eslint/switch-exhaustiveness-check: "error" */ /** * Enumeration for tracking what operation the backend is undergoing. */ export enum Action { NONE = 'idle', STARTING = 'starting', STOPPING = 'stopping', } /** * Symbolic names for various SLIRP IP addresses. */ enum SLIRP { HOST_GATEWAY = '192.168.5.2', DNS = '192.168.5.3', GUEST_IP_ADDRESS = '192.168.5.15', } /** * Lima mount */ export interface LimaMount { location: string; writable?: boolean; '9p'?: { securityModel: string; protocolVersion: string; msize: string; cache: string; } } /** * Lima configuration */ export interface LimaConfiguration { vmType?: 'qemu' | 'vz'; rosetta?: { enabled?: boolean; binfmt?: boolean; }, arch?: 'x86_64' | 'aarch64'; images: { location: string; arch?: 'x86_64' | 'aarch64'; digest?: string; }[]; cpus?: number; memory?: number; disk?: string; mounts?: LimaMount[]; mountType: 'reverse-sshfs' | '9p' | 'virtiofs'; ssh: { localPort: number; loadDotSSHPubKeys?: boolean; } firmware?: { legacyBIOS?: boolean; } video?: { display?: string; } provision?: { mode: 'system' | 'user'; script: string; }[] containerd?: { system?: boolean; user?: boolean; } probes?: { mode: 'readiness'; description: string; script: string; hint: string; }[]; hostResolver?: { hosts?: Record; } portForwards?: Record[]; networks?: Record[]; env?: Record; } /** * QEMU Image formats */ enum ImageFormat { QCOW2 = 'qcow2', RAW = 'raw', } /** * QEMU Image Information as returned by `qemu-img info --output=json ...` */ interface QEMUImageInfo { format: string; } /** * Options passed to spawnWithCapture method */ interface SpawnOptions { expectFailure?: boolean, stderr?: boolean, env?: NodeJS.ProcessEnv, } /** * Lima networking configuration. * @see https://github.com/lima-vm/lima/blob/v0.8.0/pkg/networks/networks.go */ interface LimaNetworkConfiguration { paths: { socketVMNet: string; varRun: string; sudoers?: string; } group?: string; networks: Record; } /** * One entry from `limactl list --json` */ interface LimaListResult { name: string; status: 'Broken' | 'Stopped' | 'Running'; dir: string; arch: 'x86_64' | 'aarch64'; vmType?: 'qemu' | 'vz'; sshLocalPort?: number; hostAgentPID?: number; qemuPID?: number; errors?: string[]; } /** SPNetworkDataType is output from /usr/sbin/system_profiler on darwin. */ interface SPNetworkDataType { _name: string; interface: string; dhcp?: unknown; IPv4?: { Addresses?: string[]; }; } type SudoReason = 'networking' | 'docker-socket'; /** * SudoCommand describes an operation that will be run under sudo. This is * returned from various methods that need to determine what commands we need to * run under sudo to have all functionality. */ interface SudoCommand { /** Reason why we want sudo access, */ reason: SudoReason; /** Commands that will need to be executed. */ commands: string[]; /** Paths that will be affected by this command. */ paths: string[]; } const console = Logging.lima; const DEFAULT_DOCKER_SOCK_LOCATION = '/var/run/docker.sock'; export const MACHINE_NAME = '0'; const IMAGE_VERSION = DEPENDENCY_VERSIONS.alpineLimaISO.isoVersion; const ALPINE_EDITION = 'rd'; const ALPINE_VERSION = DEPENDENCY_VERSIONS.alpineLimaISO.alpineVersion; const ETC_RANCHER_DESKTOP_DIR = '/etc/rancher/desktop'; const CREDENTIAL_FORWARDER_SETTINGS_PATH = path.join(ETC_RANCHER_DESKTOP_DIR, 'credfwd'); const DOCKER_CREDENTIAL_PATH = '/usr/local/bin/docker-credential-rancher-desktop'; const ROOT_DOCKER_CONFIG_DIR = '/root/.docker'; const ROOT_DOCKER_CONFIG_PATH = path.join(ROOT_DOCKER_CONFIG_DIR, 'config.json'); /** The following files, and their parents up to /, must only be writable by root, * and none of them are allowed to be symlinks (lima-vm requirements). */ const VMNET_DIR = '/opt/rancher-desktop'; // Make this file the last one to be loaded by `sudoers` so others don't override needed settings. // Details at https://github.com/rancher-sandbox/rancher-desktop/issues/1444 // This path introduced in version 1.0.1 const LIMA_SUDOERS_LOCATION = '/private/etc/sudoers.d/zzzzz-rancher-desktop-lima'; // Filename used in versions 1.0.0 and earlier: const PREVIOUS_LIMA_SUDOERS_LOCATION = '/private/etc/sudoers.d/rancher-desktop-lima'; /** * LimaBackend implements all the Lima-specific functionality for Rancher * Desktop. This is used on macOS and Linux. */ // Implementation note: some of the methods of this class do not need to modify // the instance; these have an explicit this parameter [1] to narrow their view // of the class instance. Typically, they use Readonly to prevent // writing to the instance; however, as that drops all non-public fields [2] we // sometimes have to use Readonly & LimaBackend to pick them up // (though this loses the type guarantees around it not modifying the instance). // [1]: https://www.typescriptlang.org/docs/handbook/2/classes.html#this-parameters // [2]: https://github.com/microsoft/TypeScript/issues/46802 export default class LimaBackend extends events.EventEmitter implements VMBackend, VMExecutor { constructor(arch: Architecture, kubeFactory: (backend: LimaBackend) => K8s.KubernetesBackend) { super(); this.arch = arch; this.kubeBackend = kubeFactory(this); this.progressTracker = new ProgressTracker((progress) => { this.progress = progress; this.emit('progress'); }, console); if (!(process.env.RD_TEST ?? '').includes('e2e')) { process.on('exit', async() => { // Attempt to shut down any stray qemu processes. await this.lima('stop', '--force', MACHINE_NAME); }); } } readonly kubeBackend: K8s.KubernetesBackend; readonly executor = this; #containerEngineClient: ContainerEngineClient | undefined; get containerEngineClient() { if (this.#containerEngineClient) { return this.#containerEngineClient; } throw new Error('Invalid state, no container engine client available.'); } protected readonly CONFIG_PATH = path.join(paths.lima, '_config', `${ MACHINE_NAME }.yaml`); /** The current config state. */ protected cfg: BackendSettings | undefined; /** The current architecture. */ protected readonly arch: Architecture; /** The version of Kubernetes currently running. */ protected activeVersion: semver.SemVer | null = null; /** Whether we can prompt the user for administrative access - this setting persists in the config. */ #adminAccess = true; /** A transient property that prevents prompting via modal UI elements. */ #noModalDialogs = false; get noModalDialogs() { return this.#noModalDialogs; } set noModalDialogs(value: boolean) { this.#noModalDialogs = value; } /** Helper object to manage progress notifications. */ progressTracker; /** * The current operation underway; used to avoid responding to state changes * when we're in the process of doing a different one. */ currentAction: Action = Action.NONE; writeSetting(changed: RecursivePartial) { if (changed) { mainEvents.emit('settings-write', changed); } this.cfg = merge({}, this.cfg, changed); } protected internalState: State = State.STOPPED; get state() { return this.internalState; } protected async setState(state: State) { this.internalState = state; this.emit('state-changed', this.state); switch (this.state) { case State.STOPPING: case State.STOPPED: case State.ERROR: case State.DISABLED: await this.kubeBackend.cleanup(); break; case State.STARTING: case State.STARTED: /* nothing */ } } progress: BackendProgress = { current: 0, max: 0 }; debug = false; emit: VMBackend['emit'] = events.EventEmitter.prototype.emit; get backend(): 'lima' { return 'lima'; } get cpus(): Promise { return (async() => { return (await this.getLimaConfig())?.cpus || 0; })(); } get memory(): Promise { return (async() => { return Math.round(((await this.getLimaConfig())?.memory || 0) / 1024 / 1024 / 1024); })(); } protected ensureArchitectureMatch() { if (os.platform().startsWith('darwin')) { // Since we now use native Electron, the only case this might be an issue // is the user is running under Rosetta. Flag that. if (Electron.app.runningUnderARM64Translation) { // Using 'aarch64' and 'x86_64' in the error because that's what we use // for the DMG suffix, e.g. "Rancher Desktop.aarch64.dmg" throw new BackendError('Fatal Error', `Rancher Desktop for x86_64 does not work on aarch64.`, true); } } } protected async ensureVirtualizationSupported() { if (os.platform().startsWith('linux')) { const cpuInfo = await fs.promises.readFile('/proc/cpuinfo', 'utf-8'); if (this.arch === 'x86_64') { if (!/flags.*(vmx|svm)/g.test(cpuInfo)) { console.log(`Virtualization support error: got ${ cpuInfo }`); throw new Error('Virtualization does not appear to be supported on your machine.'); } } } else if (os.platform().startsWith('darwin')) { const { stdout } = await childProcess.spawnFile( 'sysctl', ['kern.hv_support'], { stdio: ['inherit', 'pipe', console] }); if (!/:\s*1$/.test(stdout.trim())) { console.log(`Virtualization support error: got ${ stdout.trim() }`); throw new Error('Virtualization does not appear to be supported on your machine.'); } } } /** * Get the IPv4 address of the VM. This address should be routable from within the VM itself. * In Lima the SLIRP guest IP address is hard-coded. */ get ipAddress(): Promise { return Promise.resolve(SLIRP.GUEST_IP_ADDRESS); } getBackendInvalidReason(): Promise { return Promise.resolve(null); } /** * Check if the base (alpine) disk image is out of date; if yes, update it * without removing existing data. This is only ever called from updateConfig * to ensure that the passed-in lima configuration is the one before we * overwrote it. * * This will stop the VM if necessary. */ protected async updateBaseDisk(currentConfig: LimaConfiguration) { // Lima does not have natively have any support for this; we'll need to // reach into the configuration and: // 1) Figure out what the old base disk version is. // 2) Confirm that it's out of date. // 3) Change out the base disk as necessary. // Unfortunately, we don't have a version string anywhere _in_ the image, so // we will have to rely on the path in lima.yml instead. const images = currentConfig.images.map(i => path.basename(i.location)); // We had a typo in the name of the image; it was "alpline" instead of "alpine". // Image version may have a '+rd1' (or '.rd1') suffix after the upstream semver version. const versionMatch = images.map(i => /^alpl?ine-lima-v([0-9.]+)(?:[+.]rd(\d+))?-/.exec(i)).find(defined); const existingVersion = semver.coerce(versionMatch?.[1]); const existingRDVersion = versionMatch?.[2]; if (!existingVersion) { console.log(`Could not find base image version from ${ images }; skipping update of base images.`); return; } let versionComparison = semver.coerce(IMAGE_VERSION)?.compare(existingVersion); // Compare RD patch versions if upstream semver are matching if (versionComparison === 0) { const rdVersionMatch = IMAGE_VERSION.match(/[+.]rd(\d+)/); if (rdVersionMatch) { if (existingRDVersion) { if (parseInt(existingRDVersion) < parseInt(rdVersionMatch[1])) { versionComparison = 1; } } else { // If the new image has an RD patch version, but the old one doesn't, then the new version is newer. versionComparison = 1; } } else if (existingRDVersion) { // If the old image has an RD patch version, but the new one doesn't, then the new version is older. versionComparison = -1; } } switch (versionComparison) { case undefined: // Could not parse desired image version console.log(`Error parsing desired image version ${ IMAGE_VERSION }`); return; case -1: { // existing version is newer const message = ` This Rancher Desktop installation appears to be older than the version that created your existing Kubernetes cluster. Please either update Rancher Desktop or reset Kubernetes and container images.`; console.log(`Base disk is ${ existingVersion }, newer than ${ IMAGE_VERSION } - aborting.`); throw new BackendError('Rancher Desktop Update Required', message.replace(/\s+/g, ' ').trim()); } case 0: // The image is the same version as what we have return; case 1: // Need to update the image. break; default: { // Should never reach this. const message = ` There was an error determining if your existing Rancher Desktop cluster needs to be updated. Please reset Kubernetes and container images, or file an issue with your Rancher Desktop logs attached.`; console.log(`Invalid valid comparing ${ existingVersion } to desired ${ IMAGE_VERSION }: ${ JSON.stringify(versionComparison) }`); throw new BackendError('Fatal Error', message.replace(/\s+/g, ' ').trim()); } } console.log(`Attempting to update base image from ${ existingVersion } to ${ IMAGE_VERSION }...`); if ((await this.status)?.status === 'Running') { // This shouldn't be possible (it should only be running if we started it // in the same Rancher Desktop instance); but just in case, we still stop // the VM anyway. await this.lima('stop', MACHINE_NAME); } const diskPath = path.join(paths.lima, MACHINE_NAME, 'basedisk'); await fs.promises.copyFile(this.baseDiskImage, diskPath); // The config file will be updated in updateConfig() instead; no need to do it here. console.log(`Base image successfully updated.`); } protected get baseDiskImage() { const imageName = `alpine-lima-v${ IMAGE_VERSION }-${ ALPINE_EDITION }-${ ALPINE_VERSION }.iso`; return path.join(paths.resources, os.platform(), imageName); } #sshPort = 0; get sshPort(): Promise { return (async() => { if (this.#sshPort === 0) { if ((await this.status)?.status === 'Running') { // if the machine is already running, we can't change the port. const existingPort = (await this.getLimaConfig())?.ssh.localPort; if (existingPort) { this.#sshPort = existingPort; return existingPort; } } const server = net.createServer(); await new Promise((resolve) => { server.once('listening', resolve); server.listen(0, '127.0.0.1'); }); this.#sshPort = (server.address() as net.AddressInfo).port; server.close(); } return this.#sshPort; })(); } protected getMounts(): LimaMount[] { const mounts: LimaMount[] = []; const locations = ['~', '/tmp/rancher-desktop']; const homeDir = `${ os.homedir() }/`; const extraDirs = [paths.cache, paths.logs, paths.resources]; if (os.platform() === 'darwin') { // /var and /tmp are symlinks to /private/var and /private/tmp locations.push('/Volumes', '/var/folders', '/private/tmp', '/private/var/folders'); } for (const extraDir of extraDirs) { const found = locations.some((loc) => { loc = loc === '~' ? homeDir : path.normalize(loc); return !path.relative(loc, path.normalize(extraDir)).startsWith('../'); }); if (!found) { locations.push(extraDir); } } for (const location of locations) { const mount: LimaMount = { location, writable: true }; if (this.cfg?.virtualMachine.mount.type === MountType.NINEP) { const nineP = this.cfg.experimental.virtualMachine.mount['9p']; mount['9p'] = { securityModel: nineP.securityModel, protocolVersion: nineP.protocolVersion, msize: `${ nineP.msizeInKib }KiB`, cache: nineP.cacheMode, }; } mounts.push(mount); } return mounts; } /** * Update the Lima configuration. This may stop the VM if the base disk image * needs to be changed. */ protected async updateConfig(allowRoot = true) { const currentConfig = await this.getLimaConfig(); const baseConfig: Partial = currentConfig || {}; // We use {} as the first argument because merge() modifies // it, and it would be less safe to modify baseConfig. const config: LimaConfiguration = merge({}, baseConfig, DEFAULT_CONFIG as LimaConfiguration, { vmType: this.cfg?.virtualMachine.type, rosetta: { enabled: this.cfg?.virtualMachine.useRosetta, binfmt: this.cfg?.virtualMachine.useRosetta, }, images: [{ location: this.baseDiskImage, arch: this.arch, }], cpus: this.cfg?.virtualMachine.numberCPUs || 4, memory: (this.cfg?.virtualMachine.memoryInGB || 4) * 1024 * 1024 * 1024, disk: this.cfg?.experimental.virtualMachine.diskSize ?? '100GiB', mounts: this.getMounts(), mountType: this.cfg?.virtualMachine.mount.type, ssh: { localPort: await this.sshPort }, hostResolver: { hosts: { // As far as lima is concerned, the instance name is 'lima-0'. // We change the hostname in a provisioning script. 'lima-rancher-desktop': 'lima-0', 'host.rancher-desktop.internal': 'host.lima.internal', 'host.docker.internal': 'host.lima.internal', }, }, }); // Alpine can boot via UEFI now if (config.firmware) { config.firmware.legacyBIOS = false; } // RD used to store additional keys in lima.yaml that are not supported by lima (and no longer used by RD). // They must be removed because lima intends to switch to strict YAML parsing, so typos can be detected. delete (config as unknown as Record).k3s; delete (config as unknown as Record).paths; if (os.platform() === 'darwin') { if (allowRoot) { const hostNetwork = (await this.getDarwinHostNetworks()).find((n) => { return n.dhcp && n.IPv4?.Addresses?.some(addr => addr); }); // Always add a shared network interface in case the bridged interface doesn't get an IP address. config.networks = [{ lima: 'rancher-desktop-shared', interface: 'rd1', }]; if (hostNetwork) { config.networks.push({ lima: `rancher-desktop-bridged_${ hostNetwork.interface }`, interface: 'rd0', }); } else { console.log('Could not find any acceptable host networks for bridging.'); } } else if (this.cfg?.virtualMachine.type === VMType.VZ) { console.log('Using vzNAT networking stack'); config.networks = [{ interface: 'vznat', vzNAT: true, }]; } else { console.log('Administrator access disallowed, not using socket_vmnet.'); delete config.networks; } } this.updateConfigPortForwards(config); if (currentConfig) { // update existing configuration const configPath = path.join(paths.lima, MACHINE_NAME, 'lima.yaml'); await this.progressTracker.action( 'Updating outdated virtual machine', 100, this.updateBaseDisk(currentConfig), ); await fs.promises.writeFile(configPath, yaml.stringify(config, { lineWidth: 0 }), 'utf-8'); } else { // new configuration await fs.promises.mkdir(path.dirname(this.CONFIG_PATH), { recursive: true }); await fs.promises.writeFile(this.CONFIG_PATH, yaml.stringify(config, { lineWidth: 0 })); if (os.platform().startsWith('darwin')) { try { await childProcess.spawnFile('tmutil', ['addexclusion', paths.lima]); } catch (ex) { console.log('Failed to add exclusion to TimeMachine', ex); } } } } protected updateConfigPortForwards(config: LimaConfiguration) { let allPortForwards: Record[] | undefined = config.portForwards; if (!allPortForwards) { // This shouldn't happen, but fix it anyway config.portForwards = allPortForwards = DEFAULT_CONFIG.portForwards ?? []; } const hostSocket = path.join(paths.altAppHome, 'docker.sock'); const dockerPortForwards = allPortForwards?.find(entry => Object.keys(entry).length === 2 && entry.guestSocket === '/var/run/docker.sock' && ('hostSocket' in entry)); if (!dockerPortForwards) { config.portForwards?.push({ guestSocket: '/var/run/docker.sock', hostSocket, }); } else { dockerPortForwards.hostSocket = hostSocket; } } protected async getLimaConfig(): Promise { try { const configPath = path.join(paths.lima, MACHINE_NAME, 'lima.yaml'); const configRaw = await fs.promises.readFile(configPath, 'utf-8'); return yaml.parse(configRaw) as LimaConfiguration; } catch (ex) { if ((ex as NodeJS.ErrnoException).code === 'ENOENT') { return undefined; } } } protected static get limactl() { return path.join(paths.resources, os.platform(), 'lima', 'bin', 'limactl'); } protected static get qemuImg() { return path.join(paths.resources, os.platform(), 'lima', 'bin', 'qemu-img'); } protected static get limaEnv() { const binDir = path.join(paths.resources, os.platform(), 'lima', 'bin'); const libDir = path.join(paths.resources, os.platform(), 'lima', 'lib'); const VMNETDir = path.join(VMNET_DIR, 'bin'); const pathList = (process.env.PATH || '').split(path.delimiter); const newPath = [binDir, VMNETDir].concat(...pathList).filter(x => x); const env = structuredClone(process.env); env.LIMA_HOME = paths.lima; env.PATH = newPath.join(path.delimiter); // Override LD_LIBRARY_PATH to pick up the QEMU libraries. // - on macOS, this is not used. The macOS dynamic linker uses DYLD_ prefixed variables. // - on packaged (rpm/deb) builds, we do not ship this directory, so it does nothing. // - for AppImage this has no effect because the libs are moved to a dir that is already on LD_LIBRARY_PATH // - this only has an effect on builds extracted from a Linux zip file (which includes a bundled // QEMU) to make sure QEMU dependencies are loaded from the bundled lib directory. if (env.LD_LIBRARY_PATH) { env.LD_LIBRARY_PATH = libDir + path.delimiter + env.LD_LIBRARY_PATH; } else { env.LD_LIBRARY_PATH = libDir; } return env; } protected static get qemuImgEnv() { return { ...process.env, LD_LIBRARY_PATH: path.join(paths.resources, os.platform(), 'lima', 'lib') }; } /** * Run `limactl` with the given arguments. */ async lima(this: Readonly, env: NodeJS.ProcessEnv, ...args: string[]): Promise; async lima(this: Readonly, ...args: string[]): Promise; async lima(this: Readonly, envOrArg: NodeJS.ProcessEnv | string, ...args: string[]): Promise { const env = LimaBackend.limaEnv; if (typeof envOrArg === 'string') { args = [envOrArg].concat(args); } else { Object.assign(env, envOrArg); } args = this.debug ? ['--debug'].concat(args) : args; try { const { stdout, stderr } = await childProcess.spawnFile(LimaBackend.limactl, args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); const formatBreak = stderr || stdout ? '\n' : ''; console.log(`> limactl ${ args.join(' ') }${ formatBreak }${ stderr }${ stdout }`); } catch (ex) { console.error(`> limactl ${ args.join(' ') }\n$`, ex); throw ex; } } /** * Run `limactl` with the given arguments, and return stdout. */ protected async limaWithCapture(this: Readonly, ...args: string[]): Promise<{ stdout: string, stderr: string }>; protected async limaWithCapture(this: Readonly, options: SpawnOptions, ...args: string[]): Promise<{ stdout: string, stderr: string }>; protected async limaWithCapture(this: Readonly, argOrOptions: string | SpawnOptions, ...args: string[]): Promise<{ stdout: string, stderr: string }> { let options: SpawnOptions = {}; if (typeof argOrOptions === 'string') { args.unshift(argOrOptions); } else { options = clone(argOrOptions); } if (this.debug) { args.unshift('--debug'); } options['env'] = LimaBackend.limaEnv; return await this.spawnWithCapture(LimaBackend.limactl, options, ...args); } async spawnWithCapture(this: Readonly, cmd: string, argOrOptions: string | SpawnOptions = {}, ...args: string[]): Promise<{ stdout: string, stderr: string }> { let options: SpawnOptions = {}; if (typeof argOrOptions === 'string') { args.unshift(argOrOptions); } else { options = clone(argOrOptions); } options.env ??= process.env; try { const { stdout, stderr } = await childProcess.spawnFile(cmd, args, { env: options.env, stdio: ['ignore', 'pipe', 'pipe'] }); const formatBreak = stderr || stdout ? '\n' : ''; console.log(`> ${ cmd } ${ args.join(' ') }${ formatBreak }${ stderr }${ stdout }`); return { stdout, stderr }; } catch (ex: any) { if (!options?.expectFailure) { console.error(`> ${ cmd } ${ args.join(' ') }\n$`, ex); if (this.debug && 'stdout' in ex) { console.error(ex.stdout); } if (this.debug && 'stderr' in ex) { console.error(ex.stderr); } } throw ex; } } /** * Run the given command within the VM. */ limaSpawn(options: execOptions, args: string[]): ChildProcess { const workDir = options.cwd ?? '.'; if (options.root) { args = ['sudo'].concat(args); } args = ['shell', `--workdir=${ workDir }`, MACHINE_NAME].concat(args); if (this.debug) { console.log(`> limactl ${ args.join(' ') }`); args.unshift('--debug'); } return spawnWithSignal( LimaBackend.limactl, args, { ...omit(options, 'cwd'), env: { ...LimaBackend.limaEnv, ...options.env ?? {} } }); } async execCommand(...command: string[]): Promise; async execCommand(options: execOptions, ...command: string[]): Promise; async execCommand(options: execOptions & { capture: true }, ...command: string[]): Promise; async execCommand(optionsOrArg: execOptions | string, ...command: string[]): Promise { let options: execOptions & { capture?: boolean } = {}; if (typeof optionsOrArg === 'string') { command = [optionsOrArg].concat(command); } else { options = optionsOrArg; } if (options.root) { command = ['sudo'].concat(command); } const expectFailure = options.expectFailure ?? false; const workDir = options.cwd ?? '.'; try { // Print a slightly different message if execution fails. const { stdout } = await this.limaWithCapture({ expectFailure: true }, 'shell', `--workdir=${ workDir }`, MACHINE_NAME, ...command); if (options.capture) { return stdout; } } catch (ex: any) { if (!expectFailure) { console.log(`Lima: executing: ${ command.join(' ') }: ${ ex }`); if (this.debug && 'stdout' in ex) { console.error('stdout:', ex.stdout); } if (this.debug && 'stderr' in ex) { console.error('stderr:', ex.stderr); } } throw ex; } } spawn(...command: string[]): childProcess.ChildProcess; spawn(options: execOptions, ...command: string[]): childProcess.ChildProcess; spawn(optionsOrCommand: string | execOptions, ...command: string[]): ChildProcess { let options: execOptions = {}; const args = command.concat(); if (typeof optionsOrCommand === 'string') { args.unshift(optionsOrCommand); } else { options = optionsOrCommand; } return this.limaSpawn(options, args); } /** * Get the current Lima VM status, or undefined if there was an error * (e.g. the machine is not registered). */ protected get status(): Promise { return (async() => { try { const { stdout } = await this.limaWithCapture('list', '--json'); const lines = stdout.split(/\r?\n/).filter(x => x.trim()); const entries = lines.map(line => JSON.parse(line) as LimaListResult); return entries.find(entry => entry.name === MACHINE_NAME); } catch (ex) { console.error('Could not parse lima status, assuming machine is unavailable.'); return undefined; } })(); } protected async imageInfo(fileName: string): Promise { try { const { stdout } = await this.spawnWithCapture(LimaBackend.qemuImg, { env: LimaBackend.qemuImgEnv }, 'info', '--output=json', '--force-share', fileName); return JSON.parse(stdout) as QEMUImageInfo; } catch { return { format: 'unknown' } as QEMUImageInfo; } } protected async convertToRaw(fileName: string): Promise { const rawFileName = `${ fileName }.raw`; await this.spawnWithCapture(LimaBackend.qemuImg, { env: LimaBackend.qemuImgEnv }, 'convert', fileName, rawFileName); await fs.promises.unlink(fileName); await fs.promises.rename(rawFileName, fileName); } protected get isRegistered(): Promise { return this.status.then(defined); } private static calcRandomTag(desiredLength: number) { // quicker to use Math.random() than pull in all the dependencies utils/string:randomStr wants return Math.random().toString().substring(2, desiredLength + 2); } /** * Show the dialog box describing why sudo is required. * * @param explanations Map of why we want sudo, and what files are affected. * @return Whether the user wants to allow the prompt. */ protected async showSudoReason(this: Readonly & this, explanations: Record): Promise { if (this.noModalDialogs || !this.cfg?.application.adminAccess) { return false; } const neverAgain = await openSudoPrompt(explanations); if (neverAgain && this.cfg) { this.writeSetting({ application: { adminAccess: false } }); return false; } return true; } /** * Run the various commands that require privileged access after prompting the * user about the details. * * @returns Whether privileged access was successful; this will also be true * if no privileged access was required. * @note This may request the root password. */ protected async installToolsWithSudo(): Promise { const randomTag = LimaBackend.calcRandomTag(8); const commands: string[] = []; const explanations: Partial> = {}; const processCommand = (cmd: SudoCommand | undefined) => { if (cmd) { commands.push(...cmd.commands); explanations[cmd.reason] = (explanations[cmd.reason] ?? []).concat(...cmd.paths); } }; if (os.platform() === 'darwin') { await this.progressTracker.action('Setting up virtual ethernet', 10, async() => { processCommand(await this.installVMNETTools()); }); await this.progressTracker.action('Setting Lima permissions', 10, async() => { processCommand(await this.ensureRunLimaLocation()); processCommand(await this.createLimaSudoersFile(randomTag)); }); } await this.progressTracker.action('Setting up Docker socket', 10, async() => { processCommand(await this.configureDockerSocket()); }); if (commands.length === 0) { return true; } const requirePassword = await this.sudoRequiresPassword(); let allowed = true; if (requirePassword) { allowed = await this.progressTracker.action( 'Expecting user permission to continue', 10, this.showSudoReason(explanations)); } if (!allowed) { this.#adminAccess = false; return false; } const singleCommand = commands.join('; '); if (singleCommand.includes("'")) { throw new Error(`Can't execute commands ${ singleCommand } because there's a single-quote in them.`); } try { if (requirePassword) { await this.sudoExec(`/bin/sh -xec '${ singleCommand }'`); } else { await childProcess.spawnFile('sudo', ['--non-interactive', '/bin/sh', '-xec', singleCommand], { stdio: ['ignore', 'pipe', 'pipe'] }); } } catch (err) { if (err instanceof Error && err.message === 'User did not grant permission.') { this.#adminAccess = false; console.error('Failed to execute sudo, falling back to unprivileged operation', err); return false; } throw err; } return true; } /** * Determine the commands required to install vmnet-related tools. */ protected async installVMNETTools(this: unknown): Promise { const sourcePath = path.join(paths.resources, os.platform(), 'lima', 'socket_vmnet'); const installedPath = VMNET_DIR; const walk = async(dir: string): Promise<[string[], string[]]> => { const fullPath = path.resolve(sourcePath, dir); const entries = await fs.promises.readdir(fullPath, { withFileTypes: true }); const directories: string[] = []; const files: string[] = []; for (const entry of entries) { if (entry.isDirectory()) { const [childDirs, childFiles] = await walk(path.join(dir, entry.name)); directories.push(path.join(dir, entry.name), ...childDirs); files.push(...childFiles); } else if (entry.isFile() || entry.isSymbolicLink()) { files.push(path.join(dir, entry.name)); } else { const childPath = path.join(fullPath, entry.name); console.error(`vmnet: Skipping unexpected file ${ childPath }`); } } return [directories, files]; }; const [directories, files] = await walk('.'); const hashesMatch = await Promise.all(files.map(async(relPath) => { const hashFile = async(fullPath: string) => { const hash = crypto.createHash('sha256'); await new Promise((resolve) => { const readStream = fs.createReadStream(fullPath); // On error, resolve to anything that won't match the expected hash; // this will trigger a copy. Using the full path is good enough here. hash.on('finish', resolve); hash.on('error', () => resolve(fullPath)); readStream.on('error', () => resolve(fullPath)); readStream.pipe(hash); }); return hash.digest('hex'); }; const sourceFile = path.normalize(path.join(sourcePath, relPath)); const installedFile = path.normalize(path.join(installedPath, relPath)); const [sourceHash, installedHash] = await Promise.all([ hashFile(sourceFile), hashFile(installedFile), ]); return sourceHash === installedHash; })); if (hashesMatch.every(matched => matched)) { return; } const workdir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-vmnet-install-')); const tarPath = path.join(workdir, 'vmnet.tar'); const commands: string[] = []; try { // Actually create the tar file using all the files, not just the // outdated ones, since we're going to need a prompt anyway. const tarStream = fs.createWriteStream(tarPath); const archive = tar.pack(); const archiveFinished = util.promisify(stream.finished)(archive as any); const newEntry = util.promisify(archive.entry.bind(archive)); const baseHeader: Partial = { mode: 0o755, uid: 0, uname: 'root', gname: 'wheel', type: 'directory', }; archive.pipe(tarStream); await newEntry({ ...baseHeader, name: path.basename(installedPath), }); for (const relPath of directories) { const info = await fs.promises.lstat(path.join(sourcePath, relPath)); await newEntry({ ...baseHeader, name: path.normalize(path.join(path.basename(installedPath), relPath)), mtime: info.mtime, }); } for (const relPath of files) { const source = path.join(sourcePath, relPath); const info = await fs.promises.lstat(source); const header: tar.Headers = { ...baseHeader, name: path.normalize(path.join(path.basename(installedPath), relPath)), mode: info.mode, mtime: info.mtime, }; if (info.isSymbolicLink()) { header.type = 'symlink'; header.linkname = await fs.promises.readlink(source); await newEntry(header); } else { header.type = 'file'; header.size = info.size; const entry = archive.entry(header); const readStream = fs.createReadStream(source); const entryFinished = util.promisify(stream.finished)(entry); readStream.pipe(entry); await entryFinished; } } archive.finalize(); await archiveFinished; const command = `tar -xf "${ tarPath }" -C "${ path.dirname(installedPath) }"`; console.log(`VMNET tools install required: ${ command }`); commands.push(command); } finally { commands.push(`rm -fr ${ workdir }`); } return { reason: 'networking', commands, paths: [VMNET_DIR], }; } /** * Create a sudoers file that has to be byte-for-byte identical to what `limactl sudoers` would create. * We can't use `limactl sudoers` because it will fail when socket_vmnet has not yet been installed at * the secure path. We don't want to ask the user twice for a password: once to install socket_vmnet, * and once more to update the sudoers file. So we try to predict what `limactl sudoers` would write. */ protected sudoersFile(config: LimaNetworkConfiguration): string { const host = config.networks['host']; const shared = config.networks['rancher-desktop-shared']; if (host.mode !== 'host') { throw new Error('host network has wrong type'); } if (shared.mode !== 'shared') { throw new Error('shared network has wrong type'); } let name = 'host'; let sudoers = `%everyone ALL=(root:wheel) NOPASSWD:NOSETENV: /bin/mkdir -m 775 -p /private/var/run # Manage "${ name }" network daemons %everyone ALL=(root:wheel) NOPASSWD:NOSETENV: \\ /opt/rancher-desktop/bin/socket_vmnet --pidfile=/private/var/run/${ name }_socket_vmnet.pid --socket-group=everyone --vmnet-mode=host --vmnet-gateway=${ host.gateway } --vmnet-dhcp-end=${ host.dhcpEnd } --vmnet-mask=${ host.netmask } /private/var/run/socket_vmnet.${ name }, \\ /usr/bin/pkill -F /private/var/run/${ name }_socket_vmnet.pid `; const networks = Object.keys(config.networks).sort(); for (const name of networks) { const prefix = 'rancher-desktop-bridged_'; if (!name.startsWith(prefix)) { continue; } sudoers += `# Manage "${ name }" network daemons %everyone ALL=(root:wheel) NOPASSWD:NOSETENV: \\ /opt/rancher-desktop/bin/socket_vmnet --pidfile=/private/var/run/${ name }_socket_vmnet.pid --socket-group=everyone --vmnet-mode=bridged --vmnet-interface=${ name.slice(prefix.length) } /private/var/run/socket_vmnet.${ name }, \\ /usr/bin/pkill -F /private/var/run/${ name }_socket_vmnet.pid `; } name = 'rancher-desktop-shared'; sudoers += `# Manage "${ name }" network daemons %everyone ALL=(root:wheel) NOPASSWD:NOSETENV: \\ /opt/rancher-desktop/bin/socket_vmnet --pidfile=/private/var/run/${ name }_socket_vmnet.pid --socket-group=everyone --vmnet-mode=shared --vmnet-gateway=${ shared.gateway } --vmnet-dhcp-end=${ shared.dhcpEnd } --vmnet-mask=${ shared.netmask } /private/var/run/socket_vmnet.${ name }, \\ /usr/bin/pkill -F /private/var/run/${ name }_socket_vmnet.pid `; return sudoers; } protected async createLimaSudoersFile(this: Readonly & this, randomTag: string): Promise { const paths: string[] = []; const commands: string[] = []; try { await fs.promises.access(PREVIOUS_LIMA_SUDOERS_LOCATION); commands.push(`rm -f ${ PREVIOUS_LIMA_SUDOERS_LOCATION }`); paths.push(PREVIOUS_LIMA_SUDOERS_LOCATION); console.debug(`Previous sudoers file ${ PREVIOUS_LIMA_SUDOERS_LOCATION } exists, will delete.`); } catch (err: any) { if (err?.code !== 'ENOENT') { console.error(`Error checking ${ PREVIOUS_LIMA_SUDOERS_LOCATION }: ${ err }; ignoring.`); } } const networkConfig = await this.installCustomLimaNetworkConfig(true); const sudoers = this.sudoersFile(networkConfig); let updateSudoers = false; try { const existingSudoers = await fs.promises.readFile(LIMA_SUDOERS_LOCATION, { encoding: 'utf-8' }); if (sudoers !== existingSudoers) { updateSudoers = true; } } catch (ex: any) { if (ex?.code !== 'ENOENT') { throw ex; } updateSudoers = true; console.debug(`Sudoers file ${ LIMA_SUDOERS_LOCATION } does not exist, creating.`); } if (updateSudoers) { const tmpFile = path.join(os.tmpdir(), `rd-sudoers${ randomTag }.txt`); await fs.promises.writeFile(tmpFile, sudoers, { mode: 0o644 }); commands.push(`mkdir -p "${ path.dirname(LIMA_SUDOERS_LOCATION) }" && cp "${ tmpFile }" ${ LIMA_SUDOERS_LOCATION } && rm -f "${ tmpFile }"`); paths.push(LIMA_SUDOERS_LOCATION); console.debug(`Sudoers file ${ LIMA_SUDOERS_LOCATION } needs to be updated.`); } if (commands.length > 0) { return { reason: 'networking', commands, paths, }; } } protected async ensureRunLimaLocation(this: unknown): Promise { const limaRunLocation: string = NETWORKS_CONFIG.paths.varRun; const commands: string[] = []; let dirInfo: fs.Stats | null; try { dirInfo = await fs.promises.stat(limaRunLocation); // If it's owned by root and not readable by others, it's fine if (dirInfo.uid === 0 && (dirInfo.mode & fs.constants.S_IWOTH) === 0) { return; } } catch (err) { dirInfo = null; if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.log(`Unexpected situation with ${ limaRunLocation }, stat => error ${ err }`, err); throw err; } } if (!dirInfo) { commands.push(`mkdir -p ${ limaRunLocation }`); commands.push(`chmod 755 ${ limaRunLocation }`); } commands.push(`chown -R root:daemon ${ limaRunLocation }`); commands.push(`chmod -R o-w ${ limaRunLocation }`); return { reason: 'networking', commands, paths: [limaRunLocation], }; } protected async configureDockerSocket(this: Readonly & this): Promise { if (this.cfg?.containerEngine.name !== ContainerEngine.MOBY) { return; } const realPath = await this.evalSymlink(DEFAULT_DOCKER_SOCK_LOCATION); const targetPath = path.join(paths.altAppHome, 'docker.sock'); if (realPath === targetPath) { return; } return { reason: 'docker-socket', commands: [`ln -sf "${ targetPath }" "${ DEFAULT_DOCKER_SOCK_LOCATION }"`], paths: [DEFAULT_DOCKER_SOCK_LOCATION], }; } protected async evalSymlink(this: Readonly, path: string): Promise { // Use lstat.isSymbolicLink && readlink(path) to walk symlinks, // instead of fs.readlink(file) to show both where a symlink is // supposed to point, whether or not the referent exists right now. // Do this because the lima docker.sock (the referent) is deleted when lima shuts down. // Most of the time /var/run/docker.sock points directly to the lima socket, but // this code allows intermediate symlinks. try { while ((await fs.promises.lstat(path)).isSymbolicLink()) { path = await fs.promises.readlink(path); } } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.log(`Error trying to resolve symbolic link ${ path }:`, err); } } return path; } protected async sudoRequiresPassword() { try { // Check if we can run /usr/bin/true (or /bin/true) without requiring a password await childProcess.spawnFile('sudo', ['--non-interactive', '--reset-timestamp', 'true'], { stdio: ['ignore', 'pipe', 'pipe'] }); console.debug("sudo --non-interactive didn't throw an error, so assume we can do passwordless sudo"); return false; } catch (err: any) { console.debug(`sudo --non-interactive threw an error, so assume it needs a password: ${ JSON.stringify(err) }`); return true; } } /** * Use the sudo-prompt library to run the script as root * @param command: Path to an executable file */ protected async sudoExec(this: unknown, command: string) { await new Promise((resolve, reject) => { sudo(command, { name: 'Rancher Desktop' }, (error, stdout, stderr) => { if (stdout) { console.log(`Prompt for sudo: stdout: ${ stdout }`); } if (stderr) { console.log(`Prompt for sudo: stderr: ${ stderr }`); } if (error) { reject(error); } else { resolve(); } }); }); } /** * Provide a default network config file with rancher-desktop specific settings. * * If there's an existing file, replace it if it doesn't contain a * paths.varRun setting for rancher-desktop */ protected async installCustomLimaNetworkConfig(allowRoot = true): Promise { const networkPath = path.join(paths.lima, '_config', 'networks.yaml'); let config: LimaNetworkConfiguration; try { config = yaml.parse(await fs.promises.readFile(networkPath, 'utf8')); if (config?.paths?.varRun !== NETWORKS_CONFIG.paths.varRun) { const backupName = networkPath.replace(/\.yaml$/, '.orig.yaml'); await fs.promises.rename(networkPath, backupName); console.log(`Lima network configuration has unexpected contents; existing file renamed as ${ backupName }.`); config = clone(NETWORKS_CONFIG); } } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.log(`Existing networks.yaml file ${ networkPath } not yaml-parsable, got error ${ err }. It will be replaced.`); } config = clone(NETWORKS_CONFIG); } config.paths['socketVMNet'] = '/opt/rancher-desktop/bin/socket_vmnet'; // Clean up deprecated keys in config.paths, if present. // These keys are no longer used and may cause errors // during rdctl shutdown when upgrading to version // 1.20.x or later. delete (config.paths as any)['vdeSwitch']; delete (config.paths as any)['vdeVMNet']; if (config.group === 'staff') { config.group = 'everyone'; } for (const key of Object.keys(config.networks)) { if (key.startsWith('rancher-desktop-bridged_')) { delete config.networks[key]; } } if (allowRoot) { for (const hostNetwork of await this.getDarwinHostNetworks()) { // Indiscriminately add all host networks, whether they _currently_ have // DHCP / IPv4 addresses. if (hostNetwork.interface) { config.networks[`rancher-desktop-bridged_${ hostNetwork.interface }`] = { mode: 'bridged', interface: hostNetwork.interface, }; } } const sudoersPath = config.paths.sudoers; // Explanation of this rename at definition of PREVIOUS_LIMA_SUDOERS_LOCATION if (!sudoersPath || sudoersPath === PREVIOUS_LIMA_SUDOERS_LOCATION) { config.paths.sudoers = LIMA_SUDOERS_LOCATION; } } else { delete config.paths.sudoers; } await fs.promises.writeFile(networkPath, yaml.stringify(config), { encoding: 'utf-8' }); return config; } /** * Get host networking information on a darwin system. */ protected async getDarwinHostNetworks(): Promise { const { stdout } = await childProcess.spawnFile('/usr/sbin/system_profiler', ['SPNetworkDataType', '-json', '-detailLevel', 'basic'], { stdio: ['ignore', 'pipe', console] }); return JSON.parse(stdout).SPNetworkDataType; } protected async configureContainerEngine(): Promise { try { const configureWASM = !!this.cfg?.experimental?.containerEngine?.webAssembly?.enabled; const mobyStorageDriver = this.cfg?.containerEngine?.mobyStorageDriver ?? 'auto'; await this.writeFile('/usr/local/bin/nerdctl', NERDCTL, 0o755); await this.execCommand({ root: true }, 'mkdir', '-p', '/etc/cni/net.d'); if (this.cfg?.kubernetes.options.flannel) { await this.writeFile('/etc/cni/net.d/10-flannel.conflist', FLANNEL_CONFLIST); } const promises: Promise[] = []; promises.push(BackendHelper.configureContainerEngine(this, configureWASM, mobyStorageDriver)); if (configureWASM) { const version = semver.parse(DEPENDENCY_VERSIONS.spinCLI); const env = { ...process.env, KUBE_PLUGIN_VERSION: DEPENDENCY_VERSIONS.spinKubePlugin, SPIN_TEMPLATES_TAG: (version ? `spin/templates/v${ version.major }.${ version.minor }` : 'unknown'), }; promises.push(this.spawnWithCapture(executable('setup-spin'), { env })); } await Promise.all(promises); } catch (err) { console.log(`Error trying to start/update containerd: ${ err }: `, err); } } protected async configureLogrotate(): Promise { await this.writeFile('/etc/logrotate.d/lima-guestagent', LOGROTATE_LIMA_GUESTAGENT_SCRIPT, 0o644); } async readFile(filePath: string, options?: { encoding?: BufferEncoding }): Promise { const encoding = options?.encoding ?? 'utf-8'; const stdout: Buffer[] = []; const stderr: Buffer[] = []; try { // Use limaSpawn to avoid logging file contents (too verbose). const proc = this.limaSpawn({ root: true }, ['/bin/cat', filePath]); await new Promise((resolve, reject) => { proc.stdout?.on('data', (chunk: Buffer | string) => { stdout.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); }); proc.stderr?.on('data', (chunk: Buffer | string) => { stderr.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); }); proc.on('error', reject); proc.on('exit', (code, signal) => { if (code || signal) { return reject(new Error(`Failed to read ${ filePath }: /bin/cat exited with ${ code || signal }`)); } resolve(); }); }); return Buffer.concat(stdout).toString(encoding); } catch (ex: any) { console.error(`Failed to read file ${ filePath }:`, ex); if (stderr.length) { console.error(Buffer.concat(stderr).toString('utf-8')); } if (stdout.length) { console.error(Buffer.concat(stdout).toString('utf-8')); } throw ex; } } /** * Write the given contents to a given file name in the VM. * The file will be owned by root. * @param filePath The destination file path, in the VM. * @param fileContents The contents of the file. * @param permissions The file permissions. */ async writeFile(filePath: string, fileContents: string, permissions: fs.Mode = 0o644) { const workdir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `rd-${ path.basename(filePath) }-`)); const tempPath = `/tmp/${ path.basename(workdir) }.${ path.basename(filePath) }`; try { const scriptPath = path.join(workdir, path.basename(filePath)); await fs.promises.writeFile(scriptPath, fileContents, 'utf-8'); await this.lima('copy', scriptPath, `${ MACHINE_NAME }:${ tempPath }`); await this.execCommand('chmod', permissions.toString(8), tempPath); await this.execCommand({ root: true }, 'mv', tempPath, filePath); } finally { await fs.promises.rm(workdir, { recursive: true }); await this.execCommand({ root: true }, 'rm', '-f', tempPath); } } async copyFileIn(hostPath: string, vmPath: string): Promise { // TODO This logic is copied from writeFile() above and should be simplified. const workdir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `rd-${ path.basename(hostPath) }-`)); const tempPath = `/tmp/${ path.basename(workdir) }.${ path.basename(hostPath) }`; try { await this.lima('copy', hostPath, `${ MACHINE_NAME }:${ tempPath }`); await this.execCommand('chmod', '644', tempPath); await this.execCommand({ root: true }, 'mv', tempPath, vmPath); } finally { await fs.promises.rm(workdir, { recursive: true }); await this.execCommand({ root: true }, 'rm', '-f', tempPath); } } copyFileOut(vmPath: string, hostPath: string): Promise { return this.lima('copy', `${ MACHINE_NAME }:${ vmPath }`, hostPath); } /** * Get IPv4 address for specified interface. */ async getInterfaceAddr(iface: string) { try { const ipAddr = await this.execCommand({ capture: true }, 'ip', '--family', 'inet', 'addr', 'show', iface); const match = / inet ([0-9.]+)/.exec(ipAddr); return match ? match[1] : ''; } catch (ex: any) { console.error(`Could not get address for ${ iface }: ${ ex?.stderr || ex }`); return ''; } } /** * Get the network interface name and address to listen on for services; * used for flannel configuration. */ async getListeningInterface(allowSudo: boolean) { if (allowSudo) { const bridgedIP = await this.getInterfaceAddr('rd0'); if (bridgedIP) { console.log(`Using ${ bridgedIP } on bridged network rd0`); return { iface: 'rd0', addr: bridgedIP }; } const sharedIP = await this.getInterfaceAddr('rd1'); if (this.cfg?.application.adminAccess) { await this.noBridgedNetworkDialog(sharedIP); } if (sharedIP) { console.log(`Using ${ sharedIP } on shared network rd1`); return { iface: 'rd1', addr: sharedIP }; } console.log(`Neither bridged network rd0 nor shared network rd1 have an IPv4 address`); } if (this.cfg?.virtualMachine.type === VMType.VZ) { const vznatIP = await this.getInterfaceAddr('vznat'); if (vznatIP) { console.log(`Using ${ vznatIP } on vznat network`); return { iface: 'vznat', addr: vznatIP }; } console.log(`vznat interface does not have an IPv4 address`); } return { iface: 'eth0', addr: await this.ipAddress }; } /** * Display dialog to explain that bridged networking is not available. */ protected noBridgedNetworkDialog(sharedIP: string) { const options: Electron.NotificationConstructorOptions = { title: 'Bridged network did not get an IP address.', body: `Using shared network address ${ sharedIP }`, icon: 'info', }; if (!sharedIP) { options.body = "Shared network isn't available either. Only network access is via port forwarding to the host."; } this.emit('show-notification', options); return Promise.resolve(); } protected async writeBuildkitScripts() { await this.writeFile(`/etc/init.d/buildkitd`, SERVICE_BUILDKITD_INIT, 0o755); await this.writeFile(`/etc/conf.d/buildkitd`, SERVICE_BUILDKITD_CONF, 0o644); } protected async getResolver() { try { const limaEnv = await this.execCommand({ capture: true, root: true }, 'grep', 'LIMA_CIDATA_SLIRP_DNS', '/mnt/lima-cidata/lima.env'); const match = /LIMA_CIDATA_SLIRP_DNS=([0-9.]+)/.exec(limaEnv); return match ? match[1] : SLIRP.DNS; } catch (ex: any) { console.error(`Could not get resolver address: ${ ex?.stderr || ex }`); // This will be wrong for VZ emulation return SLIRP.DNS; } } protected async configureOpenResty(config: BackendSettings) { const allowedImagesConf = '/usr/local/openresty/nginx/conf/allowed-images.conf'; const resolver = `resolver ${ await this.getResolver() } ipv6=off;\n`; await this.writeFile(`/usr/local/openresty/nginx/conf/nginx.conf`, NGINX_CONF, 0o644); await this.writeFile(`/usr/local/openresty/nginx/conf/resolver.conf`, resolver, 0o644); await this.writeFile('/etc/logrotate.d/openresty', LOGROTATE_OPENRESTY_SCRIPT, 0o644); if (config.containerEngine.allowedImages.enabled) { const patterns = BackendHelper.createAllowedImageListConf(config.containerEngine.allowedImages); await this.writeFile(allowedImagesConf, patterns, 0o644); } else { await this.execCommand({ root: true }, 'rm', '-f', allowedImagesConf); } const obsoleteImageAllowListConf = path.join(path.dirname(allowedImagesConf), 'image-allow-list.conf'); await this.execCommand({ root: true }, 'rm', '-f', obsoleteImageAllowListConf); } /** * Write a configuration file for an OpenRC service. * @param service The name of the OpenRC service to configure. * @param settings A mapping of configuration values. This should be shell escaped. */ async writeConf(service: string, settings: Record) { const contents = Object.entries(settings).map(([key, value]) => `${ key }="${ value }"\n`).join(''); await this.writeFile(`/etc/conf.d/${ service }`, contents); } protected async installTrivy() { const trivyPath = path.join(paths.resources, 'linux', 'internal', 'trivy'); await this.lima('copy', trivyPath, `${ MACHINE_NAME }:./trivy`); await this.execCommand({ root: true }, 'mv', './trivy', '/usr/local/bin/trivy'); } /** * Start the VM. If the machine is already started, this does nothing. * Note that this does not start k3s. * @precondition The VM configuration is correct. */ protected async startVM() { let allowRoot = this.#adminAccess; // We need both the lima config + the lima network config to correctly check if we need sudo // access; but if it's denied, we need to regenerate both again to account for the change. allowRoot &&= await this.progressTracker.action('Asking for permission to run tasks as administrator', 100, this.installToolsWithSudo()); if (!allowRoot) { // sudo access was denied; re-generate the config. await this.progressTracker.action('Regenerating configuration to account for lack of permissions', 100, Promise.all([ this.updateConfig(false), this.installCustomLimaNetworkConfig(false), ])); } await this.progressTracker.action('Starting virtual machine', 100, async() => { try { const env: NodeJS.ProcessEnv = {}; env.LIMA_SSH_PORT_FORWARDER = this.cfg?.experimental.virtualMachine.sshPortForwarder ? 'true' : 'false'; await this.lima(env, 'start', '--tty=false', await this.isRegistered ? MACHINE_NAME : this.CONFIG_PATH); } finally { // Symlink the logs (especially if start failed) so the users can find them const machineDir = path.join(paths.lima, MACHINE_NAME); // Start the process, but ignore the result. fs.promises.readdir(machineDir) .then(filenames => filenames.filter(x => x.endsWith('.log')) .forEach(filename => fs.promises.symlink( path.join(path.relative(paths.logs, machineDir), filename), path.join(paths.logs, `lima.${ filename }`)) .catch(() => { }))); try { await fs.promises.rm(this.CONFIG_PATH, { force: true }); } catch (e) { console.debug(`Failed to delete ${ this.CONFIG_PATH }: ${ e }`); } } }); } async start(config_: BackendSettings): Promise { const config = this.cfg = clone(config_); let kubernetesVersion: semver.SemVer | undefined; let isDowngrade = false; await this.setState(State.STARTING); this.currentAction = Action.STARTING; this.#adminAccess = config_.application.adminAccess ?? true; this.#containerEngineClient = undefined; await this.progressTracker.action('Starting Backend', 10, async() => { try { this.ensureArchitectureMatch(); await Promise.all([ this.progressTracker.action('Ensuring virtualization is supported', 50, this.ensureVirtualizationSupported()), this.progressTracker.action('Updating cluster configuration', 50, this.updateConfig(this.#adminAccess)), ]); if (this.currentAction !== Action.STARTING) { // User aborted before we finished return; } const vmStatus = await this.status; let isVMAlreadyRunning = vmStatus?.status === 'Running'; // Virtualization Framework only supports RAW disks if (vmStatus && config.virtualMachine.type === VMType.VZ) { const diffdisk = path.join(paths.lima, MACHINE_NAME, 'diffdisk'); const { format } = await this.imageInfo(diffdisk); if (format === ImageFormat.QCOW2) { if (isVMAlreadyRunning) { await this.lima('stop', MACHINE_NAME); isVMAlreadyRunning = false; } await this.convertToRaw(diffdisk); } } // Start the VM; if it's already running, this does nothing. await this.startVM(); // Clear the diagnostic about not having Kubernetes versions mainEvents.emit('diagnostics-event', { id: 'kube-versions-available', available: true }); if (config.kubernetes.enabled) { [kubernetesVersion, isDowngrade] = await this.kubeBackend.download(config); if (kubernetesVersion === undefined) { if (isDowngrade) { // The desired version was unavailable, and the user declined a downgrade. await this.setState(State.ERROR); return; } // The desired version was unavailable, and we couldn't find a fallback. // Notify the user, and turn off Kubernetes. mainEvents.emit('diagnostics-event', { id: 'kube-versions-available', available: false }); this.writeSetting({ kubernetes: { enabled: false } }); } } if (this.currentAction !== Action.STARTING) { // User aborted before we finished return; } if ((await this.status)?.status === 'Running') { await this.progressTracker.action('Stopping existing instance', 100, async() => { await this.kubeBackend.stop(); if (isDowngrade && isVMAlreadyRunning) { // If we're downgrading, stop the VM (and start it again immediately), // to ensure there are no containers running (so we can delete files). await this.lima('stop', MACHINE_NAME); await this.startVM(); } }); } if (this.currentAction !== Action.STARTING) { // User aborted before we finished return; } if (kubernetesVersion) { await this.kubeBackend.deleteIncompatibleData(kubernetesVersion); } await Promise.all([ this.progressTracker.action('Installing CA certificates', 50, this.installCACerts()), this.progressTracker.action('Configuring image proxy', 50, this.configureOpenResty(config)), this.progressTracker.action('Configuring container engine', 50, this.configureContainerEngine()), this.progressTracker.action('Configuring logrotate', 50, this.configureLogrotate()), ]); if (config.containerEngine.allowedImages.enabled) { await this.startService('rd-openresty'); } switch (config.containerEngine.name) { case ContainerEngine.CONTAINERD: await this.startService('containerd'); try { await this.execCommand({ root: true, expectFailure: true, }, 'ctr', '--address', '/run/k3s/containerd/containerd.sock', 'namespaces', 'create', 'default'); } catch { // expecting failure because the namespace may already exist } break; case ContainerEngine.MOBY: // The string is for shell expansion, not a JS template string. // eslint-disable-next-line no-template-curly-in-string await this.writeConf('docker', { DOCKER_OPTS: '--host=unix:///var/run/docker.sock --host=unix:///var/run/docker.sock.raw ${DOCKER_OPTS:-}' }); await this.startService('docker'); break; case ContainerEngine.NONE: throw new Error('No container engine is set'); } const tasks = [ this.progressTracker.action('Installing Buildkit', 50, this.writeBuildkitScripts()), this.progressTracker.action('Installing image scanner', 50, this.installTrivy()), this.progressTracker.action('Installing credential helper', 50, this.installCredentialHelper()), ]; if (kubernetesVersion) { tasks.push(this.kubeBackend.install(config, kubernetesVersion, this.#adminAccess)); } await Promise.all(tasks); if (this.currentAction !== Action.STARTING) { // User aborted return; } switch (config.containerEngine.name) { case ContainerEngine.MOBY: this.#containerEngineClient = new MobyClient(this, `unix://${ path.join(paths.altAppHome, 'docker.sock') }`); await this.progressTracker.action('Setting docker context', 50, dockerDirManager.ensureDockerContextConfigured( this.#adminAccess, path.join(paths.altAppHome, 'docker.sock'))); break; case ContainerEngine.CONTAINERD: await this.execCommand({ root: true }, '/sbin/rc-service', '--ifnotstarted', 'buildkitd', 'start'); this.#containerEngineClient = new NerdctlClient(this); break; } const actions = [ this.progressTracker.action('Waiting for container engine client to be ready', 50, this.#containerEngineClient.waitForReady()), ]; if (kubernetesVersion) { actions.push(this.kubeBackend.start(config, kubernetesVersion)); } await Promise.all(actions); await this.setState(config.kubernetes.enabled ? State.STARTED : State.DISABLED); } catch (err) { console.error('Error starting lima:', err); await this.setState(State.ERROR); if (err instanceof BackendError) { if (!err.fatal) { return; } } throw err; } finally { this.currentAction = Action.NONE; } }); } protected async startService(serviceName: string) { await this.progressTracker.action(`Starting ${ serviceName }`, 50, async() => { await this.execCommand({ root: true }, '/sbin/rc-service', '--ifnotstarted', serviceName, 'start'); }); } protected async installCACerts(): Promise { const certs = await this.progressTracker.action('fetching certificates', 56, new Promise<(string | Buffer)[]>((resolve) => { mainEvents.once('cert-ca-certificates', resolve); mainEvents.emit('cert-get-ca-certificates'); })); const workdir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-ca-')); try { await this.progressTracker.action('removing existing certificates', 50, this.execCommand({ root: true }, '/bin/sh', '-c', 'rm -f /usr/local/share/ca-certificates/rd-*.crt')); if (certs && certs.length > 0) { await this.progressTracker.action('bundling certificates', 50, async function() { const writeStream = fs.createWriteStream(path.join(workdir, 'certs.tar')); const archive = tar.pack(); const archiveFinished = util.promisify(stream.finished)(archive as any); archive.pipe(writeStream); for (const [index, cert] of certs.entries()) { const curried = archive.entry.bind(archive, { name: `rd-${ index }.crt`, mode: 0o600, }, cert); await util.promisify(curried)(); } archive.finalize(); await archiveFinished; }); await this.progressTracker.action('copying certificates', 50, this.lima('copy', path.join(workdir, 'certs.tar'), `${ MACHINE_NAME }:/tmp/certs.tar`)); await this.progressTracker.action('extracting certificates', 50, this.execCommand({ root: true }, 'tar', 'xf', '/tmp/certs.tar', '-C', '/usr/local/share/ca-certificates/')); } } finally { await fs.promises.rm(workdir, { recursive: true, force: true }); } await this.progressTracker.action('Running update-ca-certificates', 50, this.execCommand({ root: true }, 'update-ca-certificates')); } protected async installCredentialHelper() { const credsPath = getServerCredentialsPath(); try { const stateInfo: ServerState = JSON.parse(await fs.promises.readFile(credsPath, { encoding: 'utf-8' })); const escapedPassword = stateInfo.password.replace(/\\/g, '\\\\') .replace(/'/g, "\\'"); // leading `$` is needed to escape single-quotes, as : $'abc\'xyz' const leadingDollarSign = stateInfo.password.includes("'") ? '$' : ''; const fileContents = `CREDFWD_AUTH=${ leadingDollarSign }'${ stateInfo.user }:${ escapedPassword }' CREDFWD_URL='http://${ SLIRP.HOST_GATEWAY }:${ stateInfo.port }' `; const defaultConfig = { credsStore: 'rancher-desktop' }; let existingConfig: Record; await this.execCommand({ root: true }, 'mkdir', '-p', ETC_RANCHER_DESKTOP_DIR); await this.writeFile(CREDENTIAL_FORWARDER_SETTINGS_PATH, fileContents, 0o644); await this.writeFile(DOCKER_CREDENTIAL_PATH, DOCKER_CREDENTIAL_SCRIPT, 0o755); try { existingConfig = JSON.parse(await this.execCommand({ capture: true, root: true }, 'cat', ROOT_DOCKER_CONFIG_PATH)); } catch (err: any) { await this.execCommand({ root: true }, 'mkdir', '-p', ROOT_DOCKER_CONFIG_DIR); existingConfig = {}; } merge(existingConfig, defaultConfig); if (this.cfg?.containerEngine.name === ContainerEngine.CONTAINERD) { existingConfig = BackendHelper.ensureDockerAuth(existingConfig); } await this.writeFile(ROOT_DOCKER_CONFIG_PATH, jsonStringifyWithWhiteSpace(existingConfig), 0o644); } catch (err: any) { console.log('Error trying to create/update docker credential files:', err); } } async stop(): Promise { // When we manually call stop, the subprocess will terminate, which will // cause stop to get called again. Prevent the reentrancy. // If we're in the middle of starting, also ignore the call to stop (from // the process terminating), as we do not want to shut down the VM in that // case. if (this.currentAction !== Action.NONE) { return; } this.currentAction = Action.STOPPING; this.#containerEngineClient = undefined; await this.progressTracker.action('Stopping services', 10, async() => { try { await this.setState(State.STOPPING); const status = await this.status; if (defined(status) && status.status === 'Running') { if (this.cfg?.kubernetes.enabled) { try { await this.execCommand({ root: true, expectFailure: true }, '/sbin/rc-service', '--ifstarted', 'k3s', 'stop'); } catch (ex) { console.error('Failed to stop k3s while stopping services: ', ex); } } await this.execCommand({ root: true }, '/sbin/rc-service', '--ifstarted', 'buildkitd', 'stop'); await this.execCommand({ root: true }, '/sbin/rc-service', '--ifstarted', 'docker', 'stop'); await this.execCommand({ root: true }, '/sbin/rc-service', '--ifstarted', 'containerd', 'stop'); await this.execCommand({ root: true }, '/sbin/rc-service', '--ifstarted', 'rd-openresty', 'stop'); await this.execCommand({ root: true }, '/sbin/fstrim', '/mnt/data'); // TODO: Remove try/catch once https://github.com/lima-vm/lima/issues/1381 is fixed // The first time a new VM running on VZ is stopped, it dies with a "panic". try { await this.lima('stop', MACHINE_NAME); } catch (ex) { if (status.vmType === VMType.VZ) { console.log(`limactl stop failed with ${ ex }`); } else { throw ex; } } await dockerDirManager.clearDockerContext(); } await this.setState(State.STOPPED); } catch (ex) { await this.setState(State.ERROR); throw ex; } finally { this.currentAction = Action.NONE; } }); } async del(): Promise { try { if (await this.isRegistered) { await this.progressTracker.action( 'Deleting virtual machine', 10, this.lima('delete', '--force', MACHINE_NAME)); } } catch (ex) { await this.setState(State.ERROR); throw ex; } this.cfg = undefined; } async reset(config: BackendSettings): Promise { await this.progressTracker.action('Resetting Kubernetes', 5, async() => { await this.stop(); // Start the VM, so that we can delete files. await this.startVM(); await this.kubeBackend.reset(); await this.start(config); }); } async handleSettingsUpdate(_: BackendSettings): Promise {} async requiresRestartReasons(cfg: RecursivePartial): Promise { const GiB = 1024 * 1024 * 1024; const limaConfig = await this.getLimaConfig(); const reasons: RestartReasons = {}; if (!this.cfg) { return reasons; // No need to restart if nothing exists } Object.assign(reasons, this.kubeBackend.k3sHelper.requiresRestartReasons(this.cfg, cfg, { 'experimental.virtualMachine.mount.9p.cacheMode': undefined, 'experimental.virtualMachine.mount.9p.msizeInKib': undefined, 'experimental.virtualMachine.mount.9p.protocolVersion': undefined, 'experimental.virtualMachine.mount.9p.securityModel': undefined, 'experimental.virtualMachine.sshPortForwarder': undefined, 'virtualMachine.mount.type': undefined, 'virtualMachine.type': undefined, 'virtualMachine.useRosetta': undefined, })); if (limaConfig) { Object.assign(reasons, await this.kubeBackend.requiresRestartReasons(this.cfg, cfg, { 'experimental.virtualMachine.diskSize': { current: limaConfig.disk ?? '100GiB' }, 'virtualMachine.memoryInGB': { current: (limaConfig.memory ?? 4 * GiB) / GiB }, 'virtualMachine.numberCPUs': { current: limaConfig.cpus ?? 2 }, })); } return reasons; } async getFailureDetails(exception: any): Promise { const logfile = console.path; const logLines = (await fs.promises.readFile(logfile, 'utf-8')).split('\n').slice(-10); return { lastCommand: exception[childProcess.ErrorCommand], lastCommandComment: getProgressErrorDescription(exception) ?? 'Unknown', lastLogLines: logLines, }; } // #region Events eventNames(): (keyof BackendEvents)[] { return super.eventNames() as (keyof BackendEvents)[]; } listeners( event: eventName, ): BackendEvents[eventName][] { return super.listeners(event) as BackendEvents[eventName][]; } rawListeners( event: eventName, ): BackendEvents[eventName][] { return super.rawListeners(event) as BackendEvents[eventName][]; } // #endregion } ================================================ FILE: pkg/rancher-desktop/backend/mock.ts ================================================ import events from 'events'; import fs from 'fs'; import os from 'os'; import util from 'util'; import semver from 'semver'; import { BackendEvents, BackendSettings, execOptions, RestartReasons, State, VMExecutor, } from './backend'; import { ContainerBasicOptions, ContainerComposeExecOptions, ContainerComposeOptions, ContainerComposePortOptions, ContainerEngineClient, ContainerRunClientOptions, ContainerRunOptions, ContainerStopOptions, ReadableProcess, WritableReadableProcess, } from './containerClient'; import { KubernetesBackend, KubernetesBackendEvents, KubernetesError } from './k8s'; import ProgressTracker from './progressTracker'; import K3sHelper from '@pkg/backend/k3sHelper'; import { Settings } from '@pkg/config/settings'; import { ChildProcess } from '@pkg/utils/childProcess'; import Logging, { Log } from '@pkg/utils/logging'; import { RecursivePartial } from '@pkg/utils/typeUtils'; const console = Logging.mock; export default class MockBackend extends events.EventEmitter implements VMExecutor { readonly kubeBackend: KubernetesBackend = new MockKubernetesBackend(); readonly executor = this; readonly backend = 'mock'; cfg: BackendSettings | undefined; state: State = State.STOPPED; readonly cpus = Promise.resolve(1); readonly memory = Promise.resolve(1); progress = { current: 0, max: 0 }; readonly progressTracker = new ProgressTracker((progress) => { this.progress = progress; this.emit('progress'); }); debug = false; containerEngineClient = new MockContainerEngineClient(); getBackendInvalidReason(): Promise { return Promise.resolve(null); } protected setState(state: State) { this.state = state; this.emit('state-changed', state); } async start(config: Settings): Promise { if ([State.DISABLED, State.STARTING, State.STARTED].includes(this.state)) { await this.stop(); } console.log('Starting mock backend...'); this.setState(State.STARTING); this.cfg = config; for (let i = 0; i < 10; i++) { this.progressTracker.numeric('Starting mock backend', i, 10); await util.promisify(setTimeout)(1_000); } this.progressTracker.numeric('Starting mock backend', 10, 10); await this.kubeBackend.start(config, new semver.SemVer('1.0.0')); this.setState(State.STARTED); console.log('Mock backend started'); } async stop(): Promise { console.log('Stopping mock backend...'); this.setState(State.STOPPING); await this.progressTracker.action('Stopping mock backend', 0, util.promisify(setTimeout)(1_000)); this.setState(State.STOPPED); console.log('Mock backend stopped.'); } async del(): Promise { console.log('Deleting mock backend...'); await this.stop(); } reset(config: Settings): Promise { return Promise.resolve(); } ipAddress = Promise.resolve('192.0.2.1'); getFailureDetails() { return Promise.resolve({ lastCommandComment: 'Not implemented', lastLogLines: [], }); } lastCommandComment = ''; noModalDialogs = true; async handleSettingsUpdate(_: BackendSettings): Promise {} requiresRestartReasons(config: RecursivePartial): Promise { if (!this.cfg) { return Promise.resolve({}); } return this.kubeBackend.requiresRestartReasons(this.cfg, config); } listIntegrations(): Promise> { if (os.platform() !== 'win32') { throw new Error('This is only expected on Windows'); } return Promise.resolve({ alpha: true, beta: false, gamma: 'some error', }); } // #region VMExecutor execCommand(...command: string[]): Promise; execCommand(options: execOptions, ...command: string[]): Promise; execCommand(options: execOptions & { capture: true }, ...command: string[]): Promise; execCommand(optionsOrArg: execOptions | string, ...command: string[]): Promise { const options: execOptions & { capture?: boolean } = typeof (optionsOrArg) === 'string' ? {} : optionsOrArg; const args = (typeof (optionsOrArg) === 'string' ? [optionsOrArg] : []).concat(command); if (options.capture) { return Promise.resolve(`Mock not executing ${ args.join(' ') }`); } return Promise.resolve(); } spawn(...command: string[]): ChildProcess; spawn(options: execOptions, ...command: string[]): ChildProcess; spawn(optionsOrCommand: string | execOptions, ...command: string[]): ChildProcess { return null as unknown as ChildProcess; } readFile(filePath: string, options: { encoding?: BufferEncoding } = {}): Promise { return Promise.reject('MockBackend#readFile() not implemented'); } writeFile(filePath: string, fileContents: string, permissions: fs.Mode = 0o644): Promise { return Promise.resolve(); } copyFileIn(hostPath: string, vmPath: string): Promise { return Promise.reject('MockBackend#copyFileIn() not implemented'); } copyFileOut(vmPath: string, hostPath: string): Promise { return Promise.reject('MockBackend#copyFileOut() not implemented'); } // #endregion // #region Events eventNames(): (keyof BackendEvents)[] { return super.eventNames() as (keyof BackendEvents)[]; } listeners( event: eventName, ): BackendEvents[eventName][] { return super.listeners(event) as BackendEvents[eventName][]; } rawListeners( event: eventName, ): BackendEvents[eventName][] { return super.rawListeners(event) as BackendEvents[eventName][]; } // #endregion } class MockKubernetesBackend extends events.EventEmitter implements KubernetesBackend { readonly availableVersions = Promise.resolve([]); version = ''; desiredPort = 9443; readonly k3sHelper = new K3sHelper('x86_64'); cachedVersionsOnly(): Promise { return Promise.resolve(false); } listServices() { return []; } forwardPort(namespace: string, service: string, k8sPort: number | string, hostPort: number): Promise { return Promise.resolve(12345); } cancelForward(namespace: string, service: string, k8sPort: number | string): Promise { return Promise.resolve(); } download() { return Promise.resolve([undefined, false] as const); } deleteIncompatibleData() { return Promise.resolve(); } install() { return Promise.resolve(); } start() { return Promise.resolve(); } stop() { return Promise.resolve(); } cleanup() { return Promise.resolve(); } reset() { return Promise.resolve(); } requiresRestartReasons() { return Promise.resolve({}); } // #region Events eventNames(): (keyof KubernetesBackendEvents)[] { return super.eventNames() as (keyof KubernetesBackendEvents)[]; } listeners( event: eventName, ): KubernetesBackendEvents[eventName][] { return super.listeners(event) as KubernetesBackendEvents[eventName][]; } rawListeners( event: eventName, ): KubernetesBackendEvents[eventName][] { return super.rawListeners(event) as KubernetesBackendEvents[eventName][]; } // #endregion } class MockContainerEngineClient implements ContainerEngineClient { waitForReady(): Promise { return Promise.resolve(); } readFile(imageID: string, filePath: string, options?: { encoding?: BufferEncoding; namespace?: string; }): Promise { throw new Error('Method not implemented.'); } copyFile(imageID: string, sourcePath: string, destinationDir: string, options?: { namespace?: string; }): Promise { throw new Error('Method not implemented.'); } getTags(imageName: string, options?: ContainerBasicOptions): Promise> { throw new Error('Method not implemented.'); } run(imageID: string, options?: ContainerRunOptions): Promise { throw new Error('Method not implemented.'); } stop(container: string, options?: ContainerStopOptions): Promise { throw new Error('Method not implemented.'); } composeUp(options: ContainerComposeOptions): Promise { throw new Error('Method not implemented.'); } composeDown(options?: ContainerComposeOptions): Promise { throw new Error('Method not implemented.'); } composeExec(options: ContainerComposeExecOptions): Promise { throw new Error('Method not implemented.'); } composePort(options: ContainerComposePortOptions): Promise { throw new Error('Method not implemented.'); } runClient(args: string[], stdio?: 'ignore', options?: ContainerRunClientOptions): Promise>; runClient(args: string[], stdio: Log, options?: ContainerRunClientOptions): Promise>; runClient(args: string[], stdio: 'pipe', options?: ContainerRunClientOptions): Promise<{ stdout: string; stderr: string; }>; runClient(args: string[], stdio: 'stream', options?: ContainerRunClientOptions): ReadableProcess; runClient(args: string[], stdio: 'interactive', options?: ContainerRunClientOptions): WritableReadableProcess; runClient(args: string[], stdio?: unknown, options?: ContainerRunClientOptions): unknown { return Promise.resolve({ stdout: '', stderr: '' }); } } ================================================ FILE: pkg/rancher-desktop/backend/mock_screenshots.ts ================================================ import semver from 'semver'; import { BackendSettings } from '@pkg/backend/backend'; import { KubeClient, ServiceEntry } from '@pkg/backend/kube/client'; import LimaKubernetesBackend from '@pkg/backend/kube/lima'; import WSLKubernetesBackend from '@pkg/backend/kube/wsl'; export class LimaKubernetesBackendMock extends LimaKubernetesBackend { start(config_: BackendSettings, kubernetesVersion: semver.SemVer): Promise { return super.start(config_, kubernetesVersion, () => new KubeClientMock()); } } export class WSLKubernetesBackendMock extends WSLKubernetesBackend { start(config_: BackendSettings, kubernetesVersion: semver.SemVer): Promise { return super.start(config_, kubernetesVersion, () => new KubeClientMock()); } } class KubeClientMock extends KubeClient { listServices(namespace: string | undefined = undefined): ServiceEntry[] { return [{ namespace: 'default', name: 'nginx', portName: 'http', port: 8080, listenPort: 30001, }, { namespace: 'default', name: 'wordpress', portName: 'http', port: 8080, }, { namespace: 'default', name: 'wordpress', portName: 'https', port: 443, }]; } } ================================================ FILE: pkg/rancher-desktop/backend/progressTracker.ts ================================================ import { BackendProgress } from './backend'; import { Log } from '@pkg/utils/logging'; const ErrorDescription = Symbol('progressTracker.description'); export function getProgressErrorDescription(e: any) { return e[ErrorDescription] as string | undefined; } /** * ProgressTracker is used to track the progress of multiple parallel actions. * It invokes a callback that takes a progress object as input when one of those * actions comes to a close. An "action" is effectively a Promise with some * associated metadata. * * Additionally, a "numeric" progress object can be set on ProgressTracker. * This takes precedence over any other progress object that may correspond * to an action. This can be useful for things like summarizing the overall * progress of all actions configured on the ProgressTracker. */ export default class ProgressTracker { /** * @param notify The callback to invoke on progress change. */ constructor(notify: (progress: BackendProgress) => void, log?: Log) { this.notify = notify; this.log = log; } /** * A function that will be called when there is any change in the * state of progress. */ protected notify: (progress: BackendProgress) => void; /** * Optional logger to track state changes. We will only emit debug output. */ protected log?: Log; /** * A progress object that is preferred over progress objects that * correspond to actions when passing one to .notify. Can be thought * of as an action without any associated Promise and with infinitely * high priority. */ protected numericProgress?: BackendProgress; /** * A list of pending actions. The currently running action with * the highest priority will be passed to this.notify. */ protected actionProgress: { priority: number, id: number, progress: BackendProgress }[] = []; /** * Provides the ID of the next action. */ protected nextActionID = 0; /** * Set the progress to a numeric value. Numeric progress is always shown in * preference to other progress. There may only be one active numeric * progress at a time. * @param description Descriptive text. * @param current The current numeric progress * @param max Maximum possible numeric prog */ numeric(description: string, current: number, max: number) { if (current < max) { this.numericProgress = { current, max, description, transitionTime: new Date(), }; } else { this.numericProgress = undefined; } this.update(); } /** * Register an action. * @param description Descriptive text for the action, to be shown to the user. * @param priority Only the action with the largest priority will be shown among concurrent actions. * @returns A promise that will be resolved when the passed-in promise resolves. */ action(description: string, priority: number, promise: Promise): Promise; action(description: string, priority: number, fn: () => Promise): Promise; action(description: string, priority: number, v: Promise | (() => Promise)) { const id = this.nextActionID; this.nextActionID++; this.actionProgress.push({ priority, id, progress: { current: 0, max: -1, description, transitionTime: new Date(), }, }); this.update(); this.log?.debug(`Progress: started ${ description }`); const promise = (v instanceof Promise) ? v : v(); return new Promise((resolve, reject) => { promise.then((val) => { this.actionProgress = this.actionProgress.filter(p => p.id !== id); this.update(); this.log?.debug(`Progress: finished ${ description }`); resolve(val); }).catch((ex) => { this.actionProgress = this.actionProgress.filter(p => p.id !== id); this.update(); this.log?.debug(`Progress: errored ${ description }: ${ ex?.ErrorDescription ?? ex }`); if (!(ErrorDescription in ex)) { Object.defineProperty( ex, ErrorDescription, { enumerable: false, value: description, }); } reject(ex); }); }); } /** * Invoke this.notify with the highest-priority progress object. */ protected update() { if (this.numericProgress) { this.notify(this.numericProgress); return; } if (this.actionProgress.length < 1) { // No action progress either; no active progress at all. this.notify({ current: 1, max: 1 }); return; } const { progress } = this.actionProgress.reduce((a, b) => a.priority > b.priority ? a : b); this.notify(progress); } } ================================================ FILE: pkg/rancher-desktop/backend/steve.ts ================================================ import { ChildProcess, spawn } from 'child_process'; import net from 'net'; import os from 'os'; import path from 'path'; import { setTimeout } from 'timers/promises'; import K3sHelper from '@pkg/backend/k3sHelper'; import Logging from '@pkg/utils/logging'; import paths from '@pkg/utils/paths'; const STEVE_PORT = 9443; const console = Logging.steve; /** * @description Singleton that manages the lifecycle of the Steve API */ export class Steve { private static instance: Steve; private process!: ChildProcess; private isRunning: boolean; private constructor() { this.isRunning = false; } /** * @description Checks for an existing instance of Steve. If one does not * exist, instantiate a new one. */ public static getInstance(): Steve { if (!Steve.instance) { Steve.instance = new Steve(); } return Steve.instance; } /** * @description Starts the Steve API if one is not already running. * Returns only after Steve is ready to accept connections. */ public async start() { const { pid } = this.process || { }; if (this.isRunning && pid) { console.debug(`Steve is already running with pid: ${ pid }`); return; } const osSpecificName = /^win/i.test(os.platform()) ? 'steve.exe' : 'steve'; const stevePath = path.join(paths.resources, os.platform(), 'internal', osSpecificName); const env = Object.assign({}, process.env); try { env.KUBECONFIG = await K3sHelper.findKubeConfigToUpdate('rancher-desktop'); } catch { // do nothing } this.process = spawn( stevePath, [ '--context', 'rancher-desktop', '--ui-path', path.join(paths.resources, 'rancher-dashboard'), '--offline', 'true', ], { env }, ); const { stdout, stderr } = this.process; if (!stdout || !stderr) { console.error('Unable to get child process...'); return; } stdout.on('data', (data: any) => { console.log(`stdout: ${ data }`); }); stderr.on('data', (data: any) => { console.error(`stderr: ${ data }`); }); this.process.on('close', (code: any) => { console.log(`child process exited with code ${ code }`); this.isRunning = false; }); try { await new Promise((resolve, reject) => { this.process.once('spawn', () => { this.isRunning = true; console.debug(`Spawned child pid: ${ this.process.pid }`); resolve(); }); this.process.once('error', (err) => { reject(new Error(`Failed to spawn Steve: ${ err.message }`, { cause: err })); }); }); await this.waitForReady(); } catch (ex) { console.error(ex); } } /** * Wait for Steve to be ready to accept connections. */ private async waitForReady(): Promise { const maxAttempts = 60; const delayMs = 500; for (let attempt = 1; attempt <= maxAttempts; attempt++) { if (!this.isRunning) { throw new Error('Steve process exited before becoming ready'); } if (await this.isPortReady()) { console.debug(`Steve is ready after ${ attempt } attempt(s)`); return; } await setTimeout(delayMs); } throw new Error(`Steve did not become ready after ${ maxAttempts * delayMs / 1000 } seconds`); } /** * Check if Steve is accepting connections on its port. */ private isPortReady(): Promise { return new Promise((resolve) => { const socket = new net.Socket(); socket.setTimeout(1000); socket.once('connect', () => { socket.destroy(); resolve(true); }); socket.once('error', () => { socket.destroy(); resolve(false); }); socket.once('timeout', () => { socket.destroy(); resolve(false); }); socket.connect(STEVE_PORT, '127.0.0.1'); }); } /** * Stops the Steve API. */ public stop() { if (!this.isRunning) { return; } this.process.kill('SIGINT'); } } ================================================ FILE: pkg/rancher-desktop/backend/wsl.ts ================================================ // Kubernetes backend for Windows, based on WSL2 + k3s import events from 'events'; import fs from 'fs'; import os from 'os'; import path from 'path'; import stream from 'stream'; import util from 'util'; import _ from 'lodash'; import * as reg from 'native-reg'; import semver from 'semver'; import tar from 'tar-stream'; import { BackendError, BackendEvents, BackendProgress, BackendSettings, execOptions, FailureDetails, RestartReasons, State, VMBackend, VMExecutor, } from './backend'; import BackendHelper from './backendHelper'; import { ContainerEngineClient, MobyClient, NerdctlClient } from './containerClient'; import ProgressTracker, { getProgressErrorDescription } from './progressTracker'; import DEPENDENCY_VERSIONS from '@pkg/assets/dependencies.yaml'; import FLANNEL_CONFLIST from '@pkg/assets/scripts/10-flannel.conflist'; import SERVICE_BUILDKITD_CONF from '@pkg/assets/scripts/buildkit.confd'; import SERVICE_BUILDKITD_INIT from '@pkg/assets/scripts/buildkit.initd'; import CONFIGURE_IMAGE_ALLOW_LIST from '@pkg/assets/scripts/configure-allowed-images'; import DOCKER_CREDENTIAL_SCRIPT from '@pkg/assets/scripts/docker-credential-rancher-desktop'; import INSTALL_WSL_HELPERS_SCRIPT from '@pkg/assets/scripts/install-wsl-helpers'; import LOGROTATE_K3S_SCRIPT from '@pkg/assets/scripts/logrotate-k3s'; import LOGROTATE_OPENRESTY_SCRIPT from '@pkg/assets/scripts/logrotate-openresty'; import SERVICE_SCRIPT_MOPROXY from '@pkg/assets/scripts/moproxy.initd'; import NERDCTL from '@pkg/assets/scripts/nerdctl'; import NGINX_CONF from '@pkg/assets/scripts/nginx.conf'; import SERVICE_GUEST_AGENT_INIT from '@pkg/assets/scripts/rancher-desktop-guestagent.initd'; import SERVICE_SCRIPT_CRI_DOCKERD from '@pkg/assets/scripts/service-cri-dockerd.initd'; import SERVICE_SCRIPT_K3S from '@pkg/assets/scripts/service-k3s.initd'; import SERVICE_SCRIPT_DOCKERD from '@pkg/assets/scripts/service-wsl-dockerd.initd'; import SCRIPT_DATA_WSL_CONF from '@pkg/assets/scripts/wsl-data.conf'; import WSL_EXEC from '@pkg/assets/scripts/wsl-exec'; import WSL_INIT_SCRIPT from '@pkg/assets/scripts/wsl-init'; import { ContainerEngine } from '@pkg/config/settings'; import { getServerCredentialsPath, ServerState } from '@pkg/main/credentialServer/httpCredentialHelperServer'; import mainEvents from '@pkg/main/mainEvents'; import BackgroundProcess from '@pkg/utils/backgroundProcess'; import * as childProcess from '@pkg/utils/childProcess'; import clone from '@pkg/utils/clone'; import Logging from '@pkg/utils/logging'; import paths from '@pkg/utils/paths'; import { executable } from '@pkg/utils/resources'; import { jsonStringifyWithWhiteSpace } from '@pkg/utils/stringify'; import { defined, RecursivePartial } from '@pkg/utils/typeUtils'; import type { KubernetesBackend } from './k8s'; /* eslint @typescript-eslint/switch-exhaustiveness-check: "error" */ const console = Logging.wsl; const INSTANCE_NAME = 'rancher-desktop'; const DATA_INSTANCE_NAME = 'rancher-desktop-data'; const ETC_RANCHER_DESKTOP_DIR = '/etc/rancher/desktop'; const CREDENTIAL_FORWARDER_SETTINGS_PATH = `${ ETC_RANCHER_DESKTOP_DIR }/credfwd`; const DOCKER_CREDENTIAL_PATH = '/usr/local/bin/docker-credential-rancher-desktop'; const ROOT_DOCKER_CONFIG_DIR = '/root/.docker'; const ROOT_DOCKER_CONFIG_PATH = `${ ROOT_DOCKER_CONFIG_DIR }/config.json`; /** Number of times to retry converting a path between WSL & Windows. */ const WSL_PATH_CONVERT_RETRIES = 10; /** * Enumeration for tracking what operation the backend is undergoing. */ export enum Action { NONE = 'idle', STARTING = 'starting', STOPPING = 'stopping', } /** The version of the WSL distro we expect. */ const DISTRO_VERSION = DEPENDENCY_VERSIONS.WSLDistro; /** * The list of directories that are in the data distribution (persisted across * version upgrades). */ const DISTRO_DATA_DIRS = [ '/etc/rancher', '/var/lib', ]; type wslExecOptions = execOptions & { /** Output encoding; defaults to utf16le. */ encoding?: BufferEncoding; /** The distribution to execute within. */ distro?: string; }; export default class WSLBackend extends events.EventEmitter implements VMBackend, VMExecutor { constructor(kubeFactory: (backend: WSLBackend) => KubernetesBackend) { super(); this.progressTracker = new ProgressTracker((progress) => { this.progress = progress; this.emit('progress'); }, console); this.hostSwitchProcess = new BackgroundProcess('host-switch.exe', { spawn: async() => { const exe = path.join(paths.resources, 'win32', 'internal', 'host-switch.exe'); const stream = await Logging['host-switch'].fdStream; const args: string[] = []; if (this.cfg?.kubernetes.enabled) { const k8sPort = 6443; const eth0IP = '192.168.127.2'; const k8sPortForwarding = `127.0.0.1:${ k8sPort }=${ eth0IP }:${ k8sPort }`; args.push('--port-forward', k8sPortForwarding); } return childProcess.spawn(exe, args, { stdio: ['ignore', stream, stream], windowsHide: true, }); }, shouldRun: () => Promise.resolve([State.STARTING, State.STARTED, State.DISABLED].includes(this.state)), }); this.kubeBackend = kubeFactory(this); } protected get distroFile() { return path.join(paths.resources, os.platform(), `distro-${ DISTRO_VERSION }.tar`); } /** The current config state. */ protected cfg: BackendSettings | undefined; /** Indicates whether the current installation is an Admin Install. */ #isAdminInstall: Promise | undefined; protected getIsAdminInstall(): Promise { this.#isAdminInstall ??= new Promise((resolve) => { let key; try { key = reg.openKey(reg.HKLM, 'SOFTWARE', reg.Access.READ); if (key) { const parsedValue = reg.getValue(key, 'SUSE\\RancherDesktop', 'AdminInstall'); const isAdmin = parsedValue !== null; return resolve(isAdmin); } else { console.debug('Failed to open registry key: HKEY_LOCAL_MACHINE\SOFTWARE'); } } catch (error) { console.error(`Error accessing registry: ${ error }`); } finally { reg.closeKey(key); } return resolve(false); }); return this.#isAdminInstall; } /** * Reference to the _init_ process in WSL. All other processes should be * children of this one. Note that this is busybox init, running in a custom * mount & pid namespace. */ protected process: childProcess.ChildProcess | null = null; /** * Windows-side process for the Rancher Desktop Networking, * it is used to provide DNS, DHCP and Port Forwarding * to the vm-switch that is running in the WSL VM. */ protected hostSwitchProcess: BackgroundProcess; readonly kubeBackend: KubernetesBackend; readonly executor = this; #containerEngineClient: ContainerEngineClient | undefined; get containerEngineClient() { if (this.#containerEngineClient) { return this.#containerEngineClient; } throw new Error('Invalid state, no container engine client available.'); } /** A transient property that prevents prompting via modal UI elements. */ #noModalDialogs = false; get noModalDialogs() { return this.#noModalDialogs; } set noModalDialogs(value: boolean) { this.#noModalDialogs = value; } /** * The current operation underway; used to avoid responding to state changes * when we're in the process of doing a different one. */ currentAction: Action = Action.NONE; /** Whether debug mode is enabled */ debug = false; get backend(): 'wsl' { return 'wsl'; } writeSetting(changed: RecursivePartial) { if (changed) { mainEvents.emit('settings-write', changed); } this.cfg = _.merge({}, this.cfg, changed); } /** The current user-visible state of the backend. */ protected internalState: State = State.STOPPED; get state() { return this.internalState; } protected async setState(state: State) { this.internalState = state; this.emit('state-changed', this.state); switch (this.state) { case State.STOPPING: case State.STOPPED: case State.ERROR: case State.DISABLED: await this.kubeBackend.stop(); break; case State.STARTING: case State.STARTED: /* nothing */ } } progressTracker: ProgressTracker; progress: BackendProgress = { current: 0, max: 0 }; get cpus(): Promise { // This doesn't make sense for WSL2, since that's a global configuration. return Promise.resolve(0); } get memory(): Promise { // This doesn't make sense for WSL2, since that's a global configuration. return Promise.resolve(0); } /** * List the registered WSL2 distributions. */ protected async registeredDistros({ runningOnly = false } = {}): Promise { const args = ['--list', '--quiet', runningOnly ? '--running' : undefined]; const distros = (await this.execWSL({ capture: true }, ...args.filter(defined))) .split(/\r?\n/g) .map(x => x.trim()) .filter(x => x); if (distros.length < 1) { // Return early if we find no distributions in this list; listing again // with verbose will fail if there are no distributions. return []; } const stdout = await this.execWSL({ capture: true }, '--list', '--verbose'); // As wsl.exe may be localized, don't check state here. const parser = /^[\s*]+(?.*?)\s+\w+\s+(?\d+)\s*$/; const result = stdout.trim() .split(/[\r\n]+/) .slice(1) // drop the title row .map(line => parser.exec(line)) .filter(defined) .filter(result => result.groups?.version === '2') .map(result => result.groups?.name) .filter(defined); return result.filter(x => distros.includes(x)); } protected async isDistroRegistered({ distribution = INSTANCE_NAME, runningOnly = false } = {}): Promise { const distros = await this.registeredDistros({ runningOnly }); console.log(`Registered distributions: ${ distros }`); return distros.includes(distribution || INSTANCE_NAME); } protected async getDistroVersion(): Promise { // ESLint doesn't realize we're doing inline shell scripts. // eslint-disable-next-line no-template-curly-in-string const script = '[ -e /etc/os-release ] && . /etc/os-release ; echo ${VERSION_ID:-0.1}'; return (await this.captureCommand('/bin/sh', '-c', script)).trim(); } /** * Ensure that the distribution has been installed into WSL2. * Any upgrades to the distribution should be done immediately after this. */ protected async ensureDistroRegistered(): Promise { if (!await this.isDistroRegistered()) { await this.progressTracker.action('Registering WSL distribution', 100, async() => { await fs.promises.mkdir(paths.wslDistro, { recursive: true }); try { await this.execWSL({ capture: true }, '--import', INSTANCE_NAME, paths.wslDistro, this.distroFile, '--version', '2'); } catch (ex: any) { if (!String(ex.stdout ?? '').includes('ensure virtualization is enabled')) { throw ex; } throw new BackendError('Virtualization not supported', ex.stdout, true); } }); } if (!await this.isDistroRegistered()) { throw new Error(`Error registering WSL2 distribution`); } await this.initDataDistribution(); } /** * If the WSL distribution we use to hold the data doesn't exist, create it * and copy the skeleton over from the active one. */ protected async initDataDistribution() { const workdir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-distro-')); try { if (!await this.isDistroRegistered({ distribution: DATA_INSTANCE_NAME })) { await this.progressTracker.action('Initializing WSL data', 100, async() => { try { // Create a distro archive from the main distro. // WSL seems to require a working /bin/sh for initialization. const OVERRIDE_FILES = { 'etc/wsl.conf': SCRIPT_DATA_WSL_CONF }; const REQUIRED_FILES = [ '/bin/busybox', // Base tools '/bin/mount', // Required for WSL startup '/bin/sh', // WSL requires a working shell to initialize '/lib', // Dependencies for busybox '/etc/passwd', // So WSL can spawn programs as a user ]; const archivePath = path.join(workdir, 'distro.tar'); console.log('Creating initial data distribution...'); // Make sure all the extra data directories exist await Promise.all(DISTRO_DATA_DIRS.map((dir) => { return this.execCommand('/bin/busybox', 'mkdir', '-p', dir); })); // Figure out what required files actually exist in the distro; they // may not exist on various versions. const extraFiles = (await Promise.all(REQUIRED_FILES.map(async(path) => { try { await this.execCommand({ expectFailure: true }, 'busybox', '[', '-e', path, ']'); return path; } catch (ex) { // Exception expected - the path doesn't exist return undefined; } }))).filter(defined); await this.execCommand('tar', '-cf', await this.wslify(archivePath), '-C', '/', ...extraFiles, ...DISTRO_DATA_DIRS); // The tar-stream package doesn't handle appends well (needs to // stream to a temporary file), and busybox tar doesn't support // append either. Luckily Windows ships with a bsdtar that // supports it, though it only supports short options. for (const [relPath, contents] of Object.entries(OVERRIDE_FILES)) { const absPath = path.join(workdir, 'tar', relPath); await fs.promises.mkdir(path.dirname(absPath), { recursive: true }); await fs.promises.writeFile(absPath, contents); } // msys comes with its own "tar.exe"; ensure we use the version // shipped with Windows. const tarExe = path.join(process.env.SystemRoot ?? '', 'system32', 'tar.exe'); await childProcess.spawnFile(tarExe, ['-r', '-f', archivePath, '-C', path.join(workdir, 'tar'), ...Object.keys(OVERRIDE_FILES)], { stdio: 'pipe' }); await this.execCommand('tar', '-tvf', await this.wslify(archivePath)); await this.execWSL('--import', DATA_INSTANCE_NAME, paths.wslDistroData, archivePath, '--version', '2'); } catch (ex) { console.log(`Error registering data distribution: ${ ex }`); await this.execWSL('--unregister', DATA_INSTANCE_NAME); throw ex; } }); } else { console.log('data distro already registered'); } await this.progressTracker.action('Updating WSL data', 100, async() => { // We may have extra directories (due to upgrades); copy any new ones over. const missingDirs: string[] = []; await Promise.all(DISTRO_DATA_DIRS.map(async(dir) => { try { await this.execWSL({ expectFailure: true, encoding: 'utf-8' }, '--distribution', DATA_INSTANCE_NAME, '--exec', '/bin/busybox', '[', '!', '-d', dir, ']'); missingDirs.push(dir); } catch (ex) { // Directory exists. } })); if (missingDirs.length > 0) { // Copy the new directories into the data distribution. // Note that we're not using compression, since we (kind of) don't have gzip... console.log(`Data distribution missing directories ${ missingDirs }, adding...`); const archivePath = await this.wslify(path.join(workdir, 'data.tar')); await this.execCommand('tar', '-cf', archivePath, '-C', '/', ...missingDirs); await this.execWSL('--distribution', DATA_INSTANCE_NAME, '--exec', '/bin/busybox', 'tar', '-xf', archivePath, '-C', '/'); } }); } catch (ex) { console.log('Error setting up data distribution:', ex); } finally { await fs.promises.rm(workdir, { recursive: true, maxRetries: 3 }); } } /** * Runs wsl-proxy process in the default namespace. This is to proxy * other distro's traffic from default namespace into the network namespace. */ protected async runWslProxy() { const debug = this.debug ? 'true' : 'false'; const logDir = await this.wslify(paths.logs); const logfile = path.posix.join(logDir, 'wsl-proxy.log'); try { await this.execCommand('/usr/local/bin/wsl-proxy', `-debug=${ debug }`, `-logfile=${ logfile }`); } catch (err: any) { console.log('Error trying to start wsl-proxy in default namespace:', err); } } /** * Write out /etc/hosts in the main distribution, copying the bulk of the * contents from the data distribution. */ protected async writeHostsFile(config: BackendSettings) { const virtualNetworkStaticAddr = '192.168.127.254'; const virtualNetworkGatewayAddr = '192.168.127.1'; await this.progressTracker.action('Updating /etc/hosts', 50, async() => { const contents = await fs.promises.readFile(`\\\\wsl$\\${ DATA_INSTANCE_NAME }\\etc\\hosts`, 'utf-8'); const lines = contents.split(/\r?\n/g) .filter(line => !line.includes('host.docker.internal')); const hosts = ['host.rancher-desktop.internal', 'host.docker.internal']; const extra = [ '# BEGIN Rancher Desktop configuration.', `${ virtualNetworkStaticAddr } ${ hosts.join(' ') }`, `${ virtualNetworkGatewayAddr } gateway.rancher-desktop.internal`, '# END Rancher Desktop configuration.', ].map(l => `${ l }\n`).join(''); await fs.promises.writeFile(`\\\\wsl$\\${ INSTANCE_NAME }\\etc\\hosts`, lines.join('\n') + extra, 'utf-8'); }); } /** * Mount the data distribution over. * * @returns a process that ensures the mount points stay alive by preventing * the distribution from being terminated due to being idle. It should be * killed once things are up. */ protected async mountData(): Promise { const mountRoot = '/mnt/wsl/rancher-desktop/run/data'; await this.execCommand('mkdir', '-p', mountRoot); // Only bind mount the root if it doesn't exist; because this is in the // shared mount (/mnt/wsl/), it can persist even if all of our distribution // instances terminate, as long as the WSL VM is still running. Once that // happens, it is no longer possible to unmount the bind mount... // However, there's an exception: the underlying device could have gone // missing (!); if that happens, we _can_ unmount it. const mountInfo = await this.execWSL( { capture: true, encoding: 'utf-8' }, '--distribution', DATA_INSTANCE_NAME, '--exec', 'busybox', 'cat', '/proc/self/mountinfo'); // https://www.kernel.org/doc/html/latest/filesystems/proc.html#proc-pid-mountinfo-information-about-mounts // We want fields 5 "mount point" and 10 "mount source". const matchRegex = new RegExp(String.raw` (?\S+) (?\S+) (?\S+) (?\S+) (?\S+) (?\S+) (?.*?) - (?\S+) (?\S+) (?\S+) `.trim().replace(/\s+/g, String.raw`\s+`)); const mountFields = mountInfo.split(/\r?\n/).map(line => matchRegex.exec(line)).filter(defined); let hasValidMount = false; for (const mountLine of mountFields) { const { mountPoint, mountSource: device } = mountLine.groups ?? {}; if (mountPoint !== mountRoot || !device) { continue; } // Some times we can have the mount but the disk is missing. // In that case we need to umount it, and the re-mount. try { await this.execWSL( { expectFailure: true }, '--distribution', DATA_INSTANCE_NAME, '--exec', 'busybox', 'test', '-e', device); console.debug(`Found a valid mount with ${ device }: ${ mountLine.input }`); hasValidMount = true; } catch (ex) { // Busybox returned error, the devices doesn't exist. Unmount. console.log(`Unmounting missing device ${ device }: ${ mountLine.input }`); await this.execWSL( '--distribution', DATA_INSTANCE_NAME, '--exec', 'busybox', 'umount', mountRoot); } } if (!hasValidMount) { console.log(`Did not find a valid mount, mounting ${ mountRoot }`); await this.execWSL('--distribution', DATA_INSTANCE_NAME, 'mount', '--bind', '/', mountRoot); } await Promise.all(DISTRO_DATA_DIRS.map(async(dir) => { await this.execCommand('mkdir', '-p', dir); await this.execCommand('mount', '-o', 'bind', `${ mountRoot }/${ dir.replace(/^\/+/, '') }`, dir); })); return childProcess.spawn('wsl.exe', ['--distribution', INSTANCE_NAME, '--exec', 'sh'], { windowsHide: true }); } /** * Convert a Windows path to a path in the WSL subsystem: * - Changes \s to /s * - Figures out what the /mnt/DRIVE-LETTER path should be */ async wslify(windowsPath: string, distro?: string): Promise { for (let i = 1; i <= WSL_PATH_CONVERT_RETRIES; i++) { const result: string = (await this.captureCommand({ distro }, 'wslpath', '-a', '-u', windowsPath)).trimEnd(); if (result) { return result; } console.log(`Failed to convert '${ windowsPath }' to a wsl path, retry #${ i }`); await util.promisify(setTimeout)(100); } return ''; } protected async killStaleProcesses() { // Attempting to terminate a terminated distribution is a no-op. await Promise.all([ this.execWSL('--terminate', INSTANCE_NAME), this.execWSL('--terminate', DATA_INSTANCE_NAME), this.hostSwitchProcess.stop(), ]); } /** * Copy a file from Windows to the WSL distribution. */ protected async wslInstall(windowsPath: string, targetDirectory: string, targetBasename = ''): Promise { const wslSourcePath = await this.wslify(windowsPath); const basename = path.basename(windowsPath); // Don't use `path.join` or the backslashes will come back. const targetFile = `${ targetDirectory }/${ targetBasename || basename }`; console.log(`Installing ${ windowsPath } as ${ wslSourcePath } into ${ targetFile } ...`); try { const stdout = await this.captureCommand('cp', wslSourcePath, targetFile); if (stdout) { console.log(`cp ${ windowsPath } as ${ wslSourcePath } to ${ targetFile }: ${ stdout }`); } } catch (err) { console.log(`Error trying to cp ${ windowsPath } as ${ wslSourcePath } to ${ targetFile }: ${ err }`); throw err; } } /** * Read the given file in a WSL distribution * @param [filePath] the path of the file to read. * @param [options] Optional configuration for reading the file. * @param [options.distro=INSTANCE_NAME] The distribution to read from. * @param [options.encoding='utf-8'] The encoding to use for the result. */ async readFile(filePath: string, options?: Partial<{ distro: typeof INSTANCE_NAME | typeof DATA_INSTANCE_NAME, encoding: BufferEncoding, }>) { const distro = options?.distro ?? INSTANCE_NAME; const encoding = options?.encoding ?? 'utf-8'; filePath = (await this.execCommand({ distro, capture: true }, 'busybox', 'readlink', '-f', filePath)).trim(); // Run wslpath here, to ensure that WSL generates any files we need. for (let i = 1; i <= WSL_PATH_CONVERT_RETRIES; ++i) { const windowsPath = (await this.execCommand({ distro, encoding, capture: true, }, '/bin/wslpath', '-w', filePath)).trim(); if (!windowsPath) { // Failed to convert for some reason; try again. await util.promisify(setTimeout)(100); continue; } return await fs.promises.readFile(windowsPath, options?.encoding ?? 'utf-8'); } throw new Error(`Failed to convert ${ filePath } to a Windows path.`); } /** * Write the given contents to a given file name in the given WSL distribution. * @param filePath The destination file path, in the WSL distribution. * @param fileContents The contents of the file. * @param [options] An object with fields .permissions=0o644 (the file permissions); and .distro=INSTANCE_NAME (WSL distribution to write to). */ async writeFileWSL(filePath: string, fileContents: string, options?: Partial<{ permissions: fs.Mode, distro: typeof INSTANCE_NAME | typeof DATA_INSTANCE_NAME }>) { const distro = options?.distro ?? INSTANCE_NAME; const workdir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `rd-${ path.basename(filePath) }-`)); try { const scriptPath = path.join(workdir, path.basename(filePath)); const wslScriptPath = await this.wslify(scriptPath, distro); await fs.promises.writeFile(scriptPath, fileContents.replace(/\r/g, ''), 'utf-8'); await this.execCommand({ distro }, 'busybox', 'cp', wslScriptPath, filePath); await this.execCommand({ distro }, 'busybox', 'chmod', (options?.permissions ?? 0o644).toString(8), filePath); } finally { await fs.promises.rm(workdir, { recursive: true, maxRetries: 3 }); } } /** * Write the given contents to a given file name in the VM. * The file will be owned by root. * @param filePath The destination file path, in the VM. * @param fileContents The contents of the file. * @param permissions The file permissions. */ async writeFile(filePath: string, fileContents: string, permissions: fs.Mode = 0o644) { await this.writeFileWSL(filePath, fileContents, { permissions }); } async copyFileIn(hostPath: string, vmPath: string): Promise { // Sometimes WSL has issues copying _from_ the VM. So we instead do the // copying from inside the VM. await this.execCommand('/bin/cp', '-f', '-T', await this.wslify(hostPath), vmPath); } async copyFileOut(vmPath: string, hostPath: string): Promise { // Sometimes WSL has issues copying _from_ the VM. So we instead do the // copying from inside the VM. await this.execCommand('/bin/cp', '-f', '-T', vmPath, await this.wslify(hostPath)); } /** * Run the given installation script. * @param scriptContents The installation script contents to run (in WSL). * @param scriptName An identifying label for the script's temporary directory - has no impact on functionality * @param args Arguments for the script. */ async runInstallScript(scriptContents: string, scriptName: string, ...args: string[]) { const workdir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `rd-${ scriptName }-`)); try { const scriptPath = path.join(workdir, scriptName); const wslScriptPath = await this.wslify(scriptPath); await fs.promises.writeFile(scriptPath, scriptContents.replace(/\r/g, ''), 'utf-8'); await this.execCommand('chmod', 'a+x', wslScriptPath); await this.execCommand(wslScriptPath, ...args); } finally { await fs.promises.rm(workdir, { recursive: true, maxRetries: 3 }); } } /** * Install helper tools for WSL (nerdctl integration). */ protected async installWSLHelpers() { const windowsNerdctlPath = path.join(paths.resources, 'linux', 'bin', 'nerdctl-stub'); const nerdctlPath = await this.wslify(windowsNerdctlPath); await this.runInstallScript(INSTALL_WSL_HELPERS_SCRIPT, 'install-wsl-helpers', nerdctlPath); } protected async installCredentialHelper() { const credsPath = getServerCredentialsPath(); try { const credentialServerAddr = 'host.rancher-desktop.internal:6109'; const stateInfo: ServerState = JSON.parse(await fs.promises.readFile(credsPath, { encoding: 'utf-8' })); const escapedPassword = stateInfo.password.replace(/\\/g, '\\\\') .replace(/'/g, "\\'"); // leading `$` is needed to escape single-quotes, as : $'abc\'xyz' const leadingDollarSign = stateInfo.password.includes("'") ? '$' : ''; const fileContents = `CREDFWD_AUTH=${ leadingDollarSign }'${ stateInfo.user }:${ escapedPassword }' CREDFWD_URL='http://${ credentialServerAddr }' `; const defaultConfig = { credsStore: 'rancher-desktop' }; let existingConfig: Record; await this.execCommand('mkdir', '-p', ETC_RANCHER_DESKTOP_DIR); await this.writeFile(CREDENTIAL_FORWARDER_SETTINGS_PATH, fileContents, 0o644); await this.writeFile(DOCKER_CREDENTIAL_PATH, DOCKER_CREDENTIAL_SCRIPT, 0o755); try { existingConfig = JSON.parse(await this.captureCommand('cat', ROOT_DOCKER_CONFIG_PATH)); } catch (err: any) { await this.execCommand('mkdir', '-p', ROOT_DOCKER_CONFIG_DIR); existingConfig = {}; } _.merge(existingConfig, defaultConfig); if (this.cfg?.containerEngine.name === ContainerEngine.CONTAINERD) { existingConfig = BackendHelper.ensureDockerAuth(existingConfig); } await this.writeFile(ROOT_DOCKER_CONFIG_PATH, jsonStringifyWithWhiteSpace(existingConfig), 0o644); } catch (err: any) { console.log('Error trying to create/update docker credential files:', err); } } /** * Return the Linux path to the moproxy executable. */ protected getMoproxyPath(): Promise { return this.wslify(path.join(paths.resources, 'linux', 'internal', 'moproxy')); } protected async writeProxySettings(proxy: BackendSettings['experimental']['virtualMachine']['proxy']): Promise { if (proxy.address && proxy.port) { // Write to /etc/moproxy/proxy.ini const protocol = proxy.address.startsWith('socks5://') ? 'socks5' : 'http'; const address = proxy.address.replace(/(https|http|socks5):\/\//g, ''); const contents = `[rancher-desktop-proxy]\naddress=${ address }:${ proxy.port }\nprotocol=${ protocol }\n`; const attributePrefix = protocol === 'socks5' ? 'socks' : 'http'; const username = proxy.username ? `${ attributePrefix } username=${ proxy.username }\n` : ''; const password = proxy.password ? `${ attributePrefix } password=${ proxy.password }\n` : ''; await this.writeFile(`/etc/moproxy/proxy.ini`, `${ contents }${ username }${ password }`); } else { await this.writeFile(`/etc/moproxy/proxy.ini`, '; no proxy defined'); } await this.modifyConf('moproxy', { MOPROXY_NOPROXY: proxy.noproxy.join(',') }); } /** * handleUpgrade removes all the left over files that * were renamed in between releases. */ protected async handleUpgrade(files: string[]) { for (const file of files) { try { await fs.promises.rm(file, { force: true, maxRetries: 3 }); } catch { // ignore the err from exception, since we are // removing renamed files from previous releases } } } protected async installGuestAgent(kubeVersion: semver.SemVer | undefined, cfg: BackendSettings | undefined) { const enableKubernetes = !!kubeVersion; const isAdminInstall = await this.getIsAdminInstall(); const guestAgentConfig: Record = { LOG_DIR: await this.wslify(paths.logs), GUESTAGENT_ADMIN_INSTALL: isAdminInstall ? 'true' : 'false', GUESTAGENT_KUBERNETES: enableKubernetes ? 'true' : 'false', GUESTAGENT_CONTAINERD: cfg?.containerEngine.name === ContainerEngine.CONTAINERD ? 'true' : 'false', GUESTAGENT_DOCKER: cfg?.containerEngine.name === ContainerEngine.MOBY ? 'true' : 'false', GUESTAGENT_DEBUG: this.debug ? 'true' : 'false', GUESTAGENT_K8S_SVC_ADDR: isAdminInstall && !cfg?.kubernetes.ingress.localhostOnly ? '0.0.0.0' : '127.0.0.1', }; await Promise.all([ this.writeFile('/etc/init.d/rancher-desktop-guestagent', SERVICE_GUEST_AGENT_INIT, 0o755), this.writeConf('rancher-desktop-guestagent', guestAgentConfig), ]); await this.execCommand('/sbin/rc-update', 'add', 'rancher-desktop-guestagent', 'default'); } /** * debugArg returns the given arguments in an array if the debug flag is * set, else an empty array. */ protected debugArg(...args: string[]): string[] { return this.debug ? args : []; } /** * execWSL runs wsl.exe with the given arguments, redirecting all output to * the log files. */ protected async execWSL(...args: string[]): Promise; protected async execWSL(options: wslExecOptions, ...args: string[]): Promise; protected async execWSL(options: wslExecOptions & { capture: true }, ...args: string[]): Promise; protected async execWSL(optionsOrArg: wslExecOptions | string, ...args: string[]): Promise { let options: wslExecOptions & { capture?: boolean } = {}; if (typeof optionsOrArg === 'string') { args = [optionsOrArg].concat(...args); } else { options = optionsOrArg; } try { let stream = options.logStream; if (!stream) { const logFile = Logging['wsl-exec']; // Write a duplicate log line so we can line up the log files. logFile.log(`Running: wsl.exe ${ args.join(' ') }`); stream = await logFile.fdStream; } // We need two separate calls so TypeScript can resolve the return values. if (options.capture) { console.debug(`Capturing output: wsl.exe ${ args.join(' ') }`); const { stdout } = await childProcess.spawnFile('wsl.exe', args, { ...options, encoding: options.encoding ?? 'utf16le', stdio: ['ignore', 'pipe', stream], }); return stdout; } console.debug(`Running: wsl.exe ${ args.join(' ') }`); await childProcess.spawnFile('wsl.exe', args, { ...options, encoding: options.encoding ?? 'utf16le', stdio: ['ignore', stream, stream], }); } catch (ex) { if (!options.expectFailure) { console.log(`WSL failed to execute wsl.exe ${ args.join(' ') }: ${ ex }`); } throw ex; } } async execCommand(...command: string[]): Promise; async execCommand(options: wslExecOptions, ...command: string[]): Promise; async execCommand(options: wslExecOptions & { capture: true }, ...command: string[]): Promise; async execCommand(optionsOrArg: wslExecOptions | string, ...command: string[]): Promise { let options: wslExecOptions = {}; const cwdOptions: string[] = []; if (typeof optionsOrArg === 'string') { command = [optionsOrArg].concat(command); } else { options = optionsOrArg; } if (options.cwd) { cwdOptions.push('--cd', options.cwd.toString()); delete options.cwd; } const expectFailure = options.expectFailure ?? false; try { // Print a slightly different message if execution fails. return await this.execWSL({ encoding: 'utf-8', ...options, expectFailure: true, }, '--distribution', options.distro ?? INSTANCE_NAME, ...cwdOptions, '--exec', ...command); } catch (ex) { if (!expectFailure) { console.log(`WSL: executing: ${ command.join(' ') }: ${ ex }`); } throw ex; } } spawn(...command: string[]): childProcess.ChildProcess; spawn(options: execOptions, ...command: string[]): childProcess.ChildProcess; spawn(optionsOrCommand: execOptions | string, ...command: string[]): childProcess.ChildProcess { const args = ['--distribution', INSTANCE_NAME, '--exec', '/usr/local/bin/wsl-exec']; if (typeof optionsOrCommand === 'string') { args.push(optionsOrCommand); } else { const options: execOptions = optionsOrCommand; // runTrivyScan() calls spawn({root: true}, …), which we ignore because we are already running as root if (options.expectFailure || options.logStream || options.env) { throw new TypeError('Not supported yet'); } } args.push(...command); return childProcess.spawn('wsl.exe', args); } /** * captureCommand runs the given command in the K3s WSL environment and returns * the standard output. * @param command The command to execute. * @returns The output of the command. */ protected async captureCommand(...command: string[]): Promise; protected async captureCommand(options: wslExecOptions, ...command: string[]): Promise; protected async captureCommand(optionsOrArg: wslExecOptions | string, ...command: string[]): Promise { let result: string; let debugArg: string; if (typeof optionsOrArg === 'string') { result = await this.execCommand({ capture: true }, optionsOrArg, ...command); debugArg = optionsOrArg; } else { result = await this.execCommand({ ...optionsOrArg, capture: true }, ...command); debugArg = JSON.stringify(optionsOrArg); } console.debug(`captureCommand:\ncommand: (${ debugArg } ${ command.map(s => `'${ s }'`).join(' ') })\noutput: <${ result }>`); return result; } /** Get the IPv4 address of the VM, assuming it's already up. */ get ipAddress(): Promise { return (async() => { // When using mirrored-mode networking, 127.0.0.1 works just fine // ...also, there may not even be an `eth0` to find the IP of! try { const networkModeString = await this.captureCommand('wslinfo', '-n', '--networking-mode'); if (networkModeString === 'mirrored') { return '127.0.0.1'; } } catch { // wslinfo is missing (wsl < 2.0.4) - fall back to old behavior } // We need to locate the _local_ route (netmask) for eth0, and then // look it up in /proc/net/fib_trie to find the local address. const routesString = await this.captureCommand('cat', '/proc/net/route'); const routes = routesString.split(/\r?\n/).map(line => line.split(/\s+/)); const route = routes.find(route => route[0] === 'eth0' && route[1] !== '00000000'); if (!route) { return undefined; } const net = Array.from(route[1].matchAll(/../g)).reverse().map(n => parseInt(n.toString(), 16)).join('.'); const trie = await this.captureCommand('cat', '/proc/net/fib_trie'); const lines = _.takeWhile(trie.split(/\r?\n/).slice(1), line => /^\s/.test(line)); const iface = _.dropWhile(lines, line => !line.includes(`${ net }/`)); const addr = iface.find((_, i, array) => array[i + 1]?.includes('/32 host LOCAL')); return addr?.split(/\s+/).pop(); })(); } async getBackendInvalidReason(): Promise { // Check if wsl.exe is available try { await this.isDistroRegistered(); } catch (ex: any) { const stdout = String(ex.stdout || ''); const isWSLMissing = (ex as NodeJS.ErrnoException).code === 'ENOENT'; const isInvalidUsageError = stdout.includes('Usage: ') && !stdout.includes('--exec'); if (isWSLMissing || isInvalidUsageError) { console.log('Error launching WSL: it does not appear to be installed.'); const message = ` Windows Subsystem for Linux does not appear to be installed. Please install it manually: https://docs.microsoft.com/en-us/windows/wsl/install `.replace(/[ \t]{2,}/g, '').trim(); return new BackendError('Error: WSL Not Installed', message, true); } throw ex; } return null; } /** * Check the WSL distribution version is acceptable; upgrade the distro * version if it is too old. * @precondition The distribution is already registered. */ protected async upgradeDistroAsNeeded() { let existingVersion = await this.getDistroVersion(); if (!semver.valid(existingVersion, true)) { existingVersion += '.0'; } let desiredVersion = DISTRO_VERSION; if (!semver.valid(desiredVersion, true)) { desiredVersion += '.0'; } if (semver.lt(existingVersion, desiredVersion, true)) { // Make sure we copy the data over before we delete the old distro await this.progressTracker.action('Upgrading WSL distribution', 100, async() => { await this.initDataDistribution(); await this.execWSL('--unregister', INSTANCE_NAME); await this.ensureDistroRegistered(); }); } } /** * Runs /sbin/init in the Rancher Desktop WSL2 distribution. * This manages {this.process}. */ protected async runInit() { const logFile = Logging['wsl-init']; const PID_FILE = '/run/wsl-init.pid'; const streamReaders: Promise[] = []; // Delete any stale wsl-init PID file try { await this.execCommand('rm', '-f', PID_FILE); } catch { } await this.writeFile('/usr/local/bin/wsl-init', WSL_INIT_SCRIPT, 0o755); // The process should already be gone by this point, but make sure. this.process?.kill('SIGTERM'); const env: Record = { ...process.env, WSLENV: `${ process.env.WSLENV }:DISTRO_DATA_DIRS:LOG_DIR/p:RD_DEBUG`, DISTRO_DATA_DIRS: DISTRO_DATA_DIRS.join(':'), LOG_DIR: paths.logs, }; if (this.debug) { env.RD_DEBUG = '1'; } this.process = childProcess.spawn('wsl.exe', ['--distribution', INSTANCE_NAME, '--exec', '/usr/local/bin/wsl-init'], { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); for (const readable of [this.process.stdout, this.process.stderr]) { if (readable) { readable.on('data', (chunk: Buffer | string) => { logFile.log(chunk.toString().trimEnd()); }); streamReaders.push(stream.promises.finished(readable)); } } this.process.on('exit', async(status, signal) => { await Promise.allSettled(streamReaders); if ([0, null].includes(status) && ['SIGTERM', null].includes(signal)) { console.log('/sbin/init exited gracefully.'); await this.stop(); } else { console.log(`/sbin/init exited with status ${ status } signal ${ signal }`); await this.stop(); await this.setState(State.ERROR); } }); // Wait for the PID file const startTime = Date.now(); const waitTime = 1_000; const maxWaitTime = 30_000; while (true) { try { const stdout = await this.captureCommand({ expectFailure: true }, 'cat', PID_FILE); console.debug(`Read wsl-init.pid: ${ stdout.trim() }`); break; } catch (e) { console.debug(`Error testing for wsl-init.pid: ${ e } (will retry)`); } if (Date.now() - startTime > maxWaitTime) { throw new Error(`Timed out after waiting for /run/wsl-init.pid: ${ maxWaitTime / waitTime } secs`); } await util.promisify(setTimeout)(waitTime); } } /** * Write a configuration file for an OpenRC service. * @param service The name of the OpenRC service to configure. * @param settings A mapping of configuration values. This should be shell escaped. */ protected async writeConf(service: string, settings: Record) { const contents = Object.entries(settings).map(([key, value]) => `${ key }="${ value }"\n`).join(''); await this.writeFile(`/etc/conf.d/${ service }`, contents); } /** * Read the configuration file for an OpenRC service. * @param service The name of the OpenRC service to read. */ protected async readConf(service: string): Promise> { // Matches a k/v-pair and groups it into separated key and value, e.g.: // ["key1:"value1"", "key1", ""value1""] const confRegex = /(?:^|^)\s*?([\w]+)(?:\s*=\s*?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*(?:[\w.-])*|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/; const conf = await this.readFile(`/etc/conf.d/${ service }`); const confFields = conf.split(/\r?\n/) // Splits config in array of k/v-pairs (["key1:"value1"", "key2:"value2""]) // Maps the array into [["key1:"value1"", "key1", ""value1""], ["key2:"value2"", "key2", ""value2""]] .map(line => confRegex.exec(line)) .filter(defined); return confFields.reduce((res, curr) => { const key = curr[1]; const value = curr[2].replace(/^(['"])([\s\S]*)\1$/mg, '$2'); // Removes redundant quotes from value return { ...res, ...{ [key]: value } }; }, {} as Record); } /** * Updates a service config with the given settings. * @param service The name of the OpenRC service to configure. * @param settings A mapping of configuration values. */ protected async modifyConf(service: string, settings: Record) { const current = await this.readConf(service); const contents = { ...current, ...settings }; await this.writeConf(service, contents); } /** * Execute a command on a given OpenRC service. * * @param service The name of the OpenRC service to execute. * @param action The name of the OpenRC service action to execute. * @param argument Argument to pass to `wsl-service` (`--ifnotstart`, `--ifstarted`) */ async execService(service: string, action: string, argument = '') { await this.execCommand('/usr/local/bin/wsl-service', argument, service, action); } /** * Start the given OpenRC service. This should only happen after * provisioning, to ensure that provisioning can modify any configuration. * * @param service The name of the OpenRC service to execute. */ async startService(service: string) { await this.execCommand('/sbin/rc-update', '--update'); await this.execService(service, 'start', '--ifnotstarted'); } /** * Stop the given OpenRC service. * * @param service The name of the OpenRC service to stop. */ async stopService(service: string) { await this.execService(service, 'stop', '--ifstarted'); } /** * Verify that the given command runs successfully * @param command */ async verifyReady(...command: string[]) { const startTime = Date.now(); const maxWaitTime = 60_000; const waitTime = 500; while (true) { const currentTime = Date.now(); if ((currentTime - startTime) > maxWaitTime) { console.log(`Waited more than ${ maxWaitTime / 1000 } secs for ${ command.join(' ') } to succeed. Giving up.`); break; } try { await this.execCommand({ expectFailure: true }, ...command); break; } catch (err) { console.debug(`Command ${ command } failed: `, err); } await util.promisify(setTimeout)(waitTime); } } async start(config_: BackendSettings): Promise { const config = this.cfg = _.defaultsDeep(clone(config_), { containerEngine: { name: ContainerEngine.NONE } }); let kubernetesVersion: semver.SemVer | undefined; let isDowngrade = false; await this.setState(State.STARTING); this.currentAction = Action.STARTING; this.#containerEngineClient = undefined; await this.progressTracker.action('Initializing Rancher Desktop', 10, async() => { try { const prepActions = [(async() => { await this.ensureDistroRegistered(); await this.upgradeDistroAsNeeded(); await this.writeHostsFile(config); })()]; if (config.kubernetes.enabled) { prepActions.push((async() => { [kubernetesVersion, isDowngrade] = await this.kubeBackend.download(config); })()); } // Clear the diagnostic about not having Kubernetes versions mainEvents.emit('diagnostics-event', { id: 'kube-versions-available', available: true }); await this.progressTracker.action('Preparing to start', 0, Promise.all(prepActions)); if (config.kubernetes.enabled && kubernetesVersion === undefined) { if (isDowngrade) { // The desired version was unavailable, and the user declined a downgrade. this.setState(State.ERROR); return; } // The desired version was unavailable, and we couldn't find a fallback. // Notify the user, and turn off Kubernetes. mainEvents.emit('diagnostics-event', { id: 'kube-versions-available', available: false }); this.writeSetting({ kubernetes: { enabled: false } }); } if (this.currentAction !== Action.STARTING) { // User aborted before we finished return; } // If we were previously running, stop it now. await this.progressTracker.action('Stopping existing instance', 100, async() => { try { await this.execCommand({ expectFailure: true }, 'rm', '-f', '/var/log/rc.log'); } catch {} this.process?.kill('SIGTERM'); await this.killStaleProcesses(); }); const distroLock = await this.progressTracker.action('Mounting WSL data', 100, this.mountData()); try { await this.progressTracker.action('Installing container engine', 0, Promise.all([ this.progressTracker.action('Starting WSL environment', 100, async() => { const rdNetworkingDNS = 'gateway.rancher-desktop.internal'; const logPath = await this.wslify(paths.logs); const rotateConf = LOGROTATE_K3S_SCRIPT.replace(/\r/g, '') .replace('/var/log', logPath); const configureWASM = !!this.cfg?.experimental?.containerEngine?.webAssembly?.enabled; const mobyStorageDriver = this.cfg?.containerEngine?.mobyStorageDriver ?? 'auto'; await Promise.all([ this.progressTracker.action('Installing the docker-credential helper', 10, async() => { // This must run after /etc/rancher is mounted await this.installCredentialHelper(); }), this.progressTracker.action('DNS configuration', 50, () => { return new Promise((resolve) => { console.debug(`setting DNS server to ${ rdNetworkingDNS } for rancher desktop networking`); try { this.hostSwitchProcess.start(); } catch (error) { console.error('Failed to run rancher desktop networking host-switch.exe process:', error); } resolve(); }); }), this.progressTracker.action('Kubernetes dockerd compatibility', 50, async() => { await this.writeFile('/etc/init.d/cri-dockerd', SERVICE_SCRIPT_CRI_DOCKERD, 0o755); await this.writeConf('cri-dockerd', { ENGINE: config.containerEngine.name, LOG_DIR: logPath, }); }), this.progressTracker.action('Kubernetes components', 50, async() => { await this.writeFile('/etc/init.d/k3s', SERVICE_SCRIPT_K3S, 0o755); await this.writeFile('/etc/logrotate.d/k3s', rotateConf); await this.execCommand('mkdir', '-p', '/etc/cni/net.d'); if (config.kubernetes.options.flannel) { await this.writeFile('/etc/cni/net.d/10-flannel.conflist', FLANNEL_CONFLIST); } }), this.progressTracker.action('container engine components', 50, async() => { await BackendHelper.configureContainerEngine(this, configureWASM, mobyStorageDriver); await this.writeConf('containerd', { log_owner: 'root' }); await this.writeFile('/usr/local/bin/nerdctl', NERDCTL, 0o755); await this.writeFile('/etc/init.d/docker', SERVICE_SCRIPT_DOCKERD, 0o755); await this.writeConf('docker', { WSL_HELPER_BINARY: await this.getWSLHelperPath(), LOG_DIR: logPath, }); await this.writeFile(`/etc/init.d/buildkitd`, SERVICE_BUILDKITD_INIT, 0o755); await this.writeFile(`/etc/conf.d/buildkitd`, `${ SERVICE_BUILDKITD_CONF }\nlog_file=${ logPath }/buildkitd.log\n`); }), this.progressTracker.action('Proxy Config Setup', 50, async() => { await this.execCommand('mkdir', '-p', '/etc/moproxy'); await this.writeConf('moproxy', { MOPROXY_BINARY: await this.getMoproxyPath(), LOG_DIR: logPath, }); await this.writeFile('/etc/init.d/moproxy', SERVICE_SCRIPT_MOPROXY, 0o755); await this.writeProxySettings(config.experimental.virtualMachine.proxy); }), this.progressTracker.action('Configuring image proxy', 50, async() => { const allowedImagesConf = '/usr/local/openresty/nginx/conf/allowed-images.conf'; const resolver = `resolver ${ rdNetworkingDNS } ipv6=off;\n`; await this.writeFile(`/usr/local/openresty/nginx/conf/nginx.conf`, NGINX_CONF, 0o644); await this.writeFile(`/usr/local/openresty/nginx/conf/resolver.conf`, resolver, 0o644); await this.writeFile(`/etc/logrotate.d/openresty`, LOGROTATE_OPENRESTY_SCRIPT, 0o644); await this.runInstallScript(CONFIGURE_IMAGE_ALLOW_LIST, 'configure-allowed-images'); if (config.containerEngine.allowedImages.enabled) { const patterns = BackendHelper.createAllowedImageListConf(config.containerEngine.allowedImages); await this.writeFile(allowedImagesConf, patterns, 0o644); } else { await this.execCommand({ root: true }, 'rm', '-f', allowedImagesConf); } const obsoleteImageAllowListConf = path.join(path.dirname(allowedImagesConf), 'image-allow-list.conf'); await this.execCommand({ root: true }, 'rm', '-f', obsoleteImageAllowListConf); }), this.progressTracker.action('Rancher Desktop guest agent', 50, this.installGuestAgent(kubernetesVersion, this.cfg)), // Remove any residual rc artifacts from previous version this.execCommand({ root: true }, 'rm', '-f', '/etc/init.d/vtunnel-peer', '/etc/runlevels/default/vtunnel-peer', '/etc/init.d/host-resolver', '/etc/runlevels/default/host-resolver', '/etc/init.d/dnsmasq-generate', '/etc/runlevels/default/dnsmasq-generate', '/etc/init.d/dnsmasq', '/etc/runlevels/default/dnsmasq'), ]); await this.writeFile('/usr/local/bin/wsl-exec', WSL_EXEC, 0o755); await this.runInit(); if (configureWASM) { try { const version = semver.parse(DEPENDENCY_VERSIONS.spinCLI); const env = { KUBE_PLUGIN_VERSION: DEPENDENCY_VERSIONS.spinKubePlugin, SPIN_TEMPLATES_TAG: (version ? `spin/templates/v${ version.major }.${ version.minor }` : 'unknown'), }; const wslenv = Object.keys(env).join(':'); // wsl-exec is needed to correctly resolve DNS names await this.execCommand({ env: { ...process.env, ...env, WSLENV: wslenv, }, }, '/usr/local/bin/wsl-exec', await this.wslify(executable('setup-spin'))); } catch { // just ignore any errors; all the script does is installing spin plugins and templates } } // Do not await on this, as we don't want to wait until the proxy exits. this.runWslProxy().catch(console.error); }), this.progressTracker.action('Installing CA certificates', 100, this.installCACerts()), this.progressTracker.action('Installing helpers', 50, this.installWSLHelpers()), ])); if (kubernetesVersion) { const version = kubernetesVersion; const allPlatformsThresholdVersion = '1.31.0'; // We install containerd-shims as part of the container engine installation (see // BackendHelper#installContainerdShims); and we need that to finish first so that when // we install Kubernetes, we can look up the set of shims in order to create // RuntimeClasses for them. (See BackendHelper#configureRuntimeClasses.) await this.progressTracker.action('Installing Kubernetes', 0, Promise.all([ this.progressTracker.action('Writing K3s configuration', 50, async() => { const k3sConf = { PORT: config.kubernetes.port.toString(), LOG_DIR: await this.wslify(paths.logs), 'export IPTABLES_MODE': 'legacy', ENGINE: config.containerEngine.name, ADDITIONAL_ARGS: config.kubernetes.options.traefik ? '' : '--disable traefik', USE_CRI_DOCKERD: BackendHelper.requiresCRIDockerd(config.containerEngine.name, version).toString(), ALLPLATFORMS: semver.lt(version, allPlatformsThresholdVersion) ? '--all-platforms' : '', }; // Make sure the apiserver can be accessed from WSL through the internal gateway k3sConf.ADDITIONAL_ARGS += ' --tls-san gateway.rancher-desktop.internal'; // Generate certificates for the statically defined host entries. // This is useful for users connecting to the host via HTTPS. k3sConf.ADDITIONAL_ARGS += ' --tls-san host.rancher-desktop.internal'; k3sConf.ADDITIONAL_ARGS += ' --tls-san host.docker.internal'; // Add the `veth-rd-ns` IP address from inside the namespace k3sConf.ADDITIONAL_ARGS += ' --tls-san 192.168.143.1'; if (!config.kubernetes.options.flannel) { console.log(`Disabling flannel and network policy`); k3sConf.ADDITIONAL_ARGS += ' --flannel-backend=none --disable-network-policy'; } if (config.application.debug) { config.ADDITIONAL_ARGS += ' --debug'; } await this.writeConf('k3s', k3sConf); }), this.progressTracker.action('Installing k3s', 100, async() => { await this.kubeBackend.deleteIncompatibleData(version); await this.kubeBackend.install(config, version, false); })])); } } finally { distroLock.kill('SIGTERM'); } await this.progressTracker.action('Running provisioning scripts', 100, this.runProvisioningScripts()); if (config.experimental.virtualMachine.proxy.enabled && config.experimental.virtualMachine.proxy.address && config.experimental.virtualMachine.proxy.port) { await this.progressTracker.action('Starting proxy', 100, this.startService('moproxy')); } if (config.containerEngine.allowedImages.enabled) { await this.progressTracker.action('Starting image proxy', 100, this.startService('rd-openresty')); } await this.progressTracker.action('Starting container engine', 0, this.startService(config.containerEngine.name === ContainerEngine.MOBY ? 'docker' : 'containerd')); switch (config.containerEngine.name) { case ContainerEngine.CONTAINERD: await this.progressTracker.action('Starting buildkit', 0, this.startService('buildkitd')); try { await this.execCommand({ root: true, expectFailure: true, }, 'ctr', '--address', '/run/k3s/containerd/containerd.sock', 'namespaces', 'create', 'default'); } catch { // expecting failure because the namespace may already exist } this.#containerEngineClient = new NerdctlClient(this); break; case ContainerEngine.MOBY: this.#containerEngineClient = new MobyClient(this, 'npipe:////./pipe/docker_engine'); break; } // Set the kubernetes ingress address to localhost only for // a non-admin installation, if it's not already set. if (!config.kubernetes.ingress.localhostOnly && !await this.getIsAdminInstall()) { this.writeSetting({ kubernetes: { ingress: { localhostOnly: true } } }); } const tasks = [ this.progressTracker.action('Waiting for container engine to be ready', 0, this.containerEngineClient.waitForReady()), ]; if (kubernetesVersion) { tasks.push(this.progressTracker.action('Starting Kubernetes', 100, this.kubeBackend.start(config, kubernetesVersion))); } await Promise.all(tasks); await this.setState(config.kubernetes.enabled ? State.STARTED : State.DISABLED); } catch (ex) { await this.setState(State.ERROR); throw ex; } finally { this.currentAction = Action.NONE; } }); } protected async installCACerts(): Promise { const certs: (string | Buffer)[] = await new Promise((resolve) => { mainEvents.once('cert-ca-certificates', resolve); mainEvents.emit('cert-get-ca-certificates'); }); const workdir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-ca-')); try { await this.execCommand('/bin/sh', '-c', 'rm -f /usr/local/share/ca-certificates/rd-*.crt'); // Similar to Lima backends, we better use of tar here to improve the performance in case of // many certificates. if (certs && certs.length > 0) { const writeStream = fs.createWriteStream(path.join(workdir, 'certs.tar')); const archive = tar.pack(); const archiveFinished = util.promisify(stream.finished)(archive as any); archive.pipe(writeStream); for (const [index, cert] of certs.entries()) { const curried = archive.entry.bind(archive, { name: `rd-${ index }.crt`, mode: 0o600, }, cert); await util.promisify(curried)(); } archive.finalize(); await archiveFinished; await this.execCommand( 'tar', 'xf', await this.wslify(path.join(workdir, 'certs.tar')), '-C', '/usr/local/share/ca-certificates/'); } } finally { await fs.promises.rm(workdir, { recursive: true, maxRetries: 3 }); } await this.execCommand('/usr/sbin/update-ca-certificates'); } /** * Run provisioning scripts; this is done after init is started. */ protected async runProvisioningScripts() { const provisioningPath = path.join(paths.config, 'provisioning'); await fs.promises.mkdir(provisioningPath, { recursive: true }); await Promise.all([ (async() => { // Write out the readme file. const ReadmePath = path.join(provisioningPath, 'README'); try { await fs.promises.access(ReadmePath, fs.constants.F_OK); } catch { const contents = `${ ` Any files named '*.start' in this directory will be executed sequentially on Rancher Desktop startup, before the main services. Files are processed in lexical order, and startup will be delayed until they have all run to completion. Similarly, any files named '*.stop' will be executed on shutdown, after the main services have exited, and delay shutdown until they have run to completion. Note that the script file names may not include whitespace. `.replace(/\s*\n\s*/g, '\n').trim() }\n`; await fs.promises.writeFile(ReadmePath, contents, { encoding: 'utf-8' }); } })(), (async() => { const linuxPath = await this.wslify(provisioningPath); // Stop the service if it's already running for some reason. // This should never be the case (because we tore down init). await this.stopService('local'); // Clobber /etc/local.d and replace it with a symlink to our desired // path. This is needed as /etc/init.d/local does not support // overriding the script directory. await this.execCommand('rm', '-r', '-f', '/etc/local.d'); await this.execCommand('ln', '-s', '-f', '-T', linuxPath, '/etc/local.d'); // Ensure all scripts are executable; Windows mounts are unlikely to // have it set by default. await this.execCommand('/usr/bin/find', '/etc/local.d/', '(', '-name', '*.start', '-o', '-name', '*.stop', ')', '-print', '-exec', 'chmod', 'a+x', '{}', ';'); // Run the script. await this.startService('local'); })(), ]); } async stop(): Promise { // When we manually call stop, the subprocess will terminate, which will // cause stop to get called again. Prevent the reentrancy. // If we're in the middle of starting, also ignore the call to stop (from // the process terminating), as we do not want to shut down the VM in that // case. if (this.currentAction !== Action.NONE) { return; } this.currentAction = Action.STOPPING; try { await this.setState(State.STOPPING); await this.kubeBackend.stop(); this.#containerEngineClient = undefined; await this.progressTracker.action('Shutting Down...', 10, async() => { if (await this.isDistroRegistered({ runningOnly: true })) { const services = ['k3s', 'docker', 'containerd', 'rd-openresty', 'rancher-desktop-guestagent', 'buildkitd']; for (const service of services) { try { await this.stopService(service); } catch (ex) { // Do not allow errors here to prevent us from stopping. console.error(`Failed to stop service ${ service }:`, ex); } } try { await this.stopService('local'); } catch (ex) { // Do not allow errors here to prevent us from stopping. console.error('Failed to run user provisioning scripts on stopping:', ex); } } const initProcess = this.process; this.process = null; if (initProcess) { initProcess.kill('SIGTERM'); try { await this.execCommand({ expectFailure: true }, '/usr/bin/killall', '/usr/local/bin/network-setup'); } catch (ex) { // `killall` returns failure if it fails to kill (e.g. if the // process does not exist); `-q` only suppresses printing any error // messages. console.error('Ignoring error shutting down network-setup:', ex); } } await this.hostSwitchProcess.stop(); if (await this.isDistroRegistered({ runningOnly: true })) { await this.execWSL('--terminate', INSTANCE_NAME); } }); await this.setState(State.STOPPED); } catch (ex) { await this.setState(State.ERROR); throw ex; } finally { this.currentAction = Action.NONE; } } async del(): Promise { await this.progressTracker.action('Deleting Kubernetes', 20, async() => { await this.stop(); if (await this.isDistroRegistered()) { await this.execWSL('--unregister', INSTANCE_NAME); } if (await this.isDistroRegistered({ distribution: DATA_INSTANCE_NAME })) { await this.execWSL('--unregister', DATA_INSTANCE_NAME); } this.cfg = undefined; }); } async reset(config: BackendSettings): Promise { await this.progressTracker.action('Resetting Kubernetes state...', 5, async() => { await this.stop(); // Mount the data first so they can be deleted correctly. const distroLock = await this.mountData(); try { await this.kubeBackend.reset(); } finally { distroLock.kill('SIGTERM'); } await this.start(config); }); } async handleSettingsUpdate(newConfig: BackendSettings): Promise { const proxy = newConfig.experimental.virtualMachine.proxy; await this.writeProxySettings(proxy); if (this.currentAction === Action.NONE && this.process) { if (proxy.enabled && proxy.address && proxy.port) { await this.execService('moproxy', 'reload', '--ifstarted'); await this.startService('moproxy'); } else { await this.stopService('moproxy'); } } } // The WSL implementation of requiresRestartReasons doesn't need to do // anything asynchronously; however, to match the API, we still need to return // a Promise. requiresRestartReasons(cfg: RecursivePartial): Promise { if (!this.cfg) { // No need to restart if nothing exists return Promise.resolve({}); } return Promise.resolve(this.kubeBackend.requiresRestartReasons( this.cfg, cfg)); } /** * Return the Linux path to the WSL helper executable. */ getWSLHelperPath(distro?: string): Promise { // We need to get the Linux path to our helper executable; it is easier to // just get WSL to do the transformation for us. return this.wslify(executable('wsl-helper-linux'), distro); } async getFailureDetails(exception: any): Promise { const loglines = (await fs.promises.readFile(console.path, 'utf-8')).split('\n').slice(-10); return { lastCommand: exception[childProcess.ErrorCommand], lastCommandComment: getProgressErrorDescription(exception) ?? 'Unknown', lastLogLines: loglines, }; } // #region Events eventNames(): (keyof BackendEvents)[] { return super.eventNames() as (keyof BackendEvents)[]; } listeners( event: eventName, ): BackendEvents[eventName][] { return super.listeners(event) as BackendEvents[eventName][]; } rawListeners( event: eventName, ): BackendEvents[eventName][] { return super.rawListeners(event) as BackendEvents[eventName][]; } // #endregion } ================================================ FILE: pkg/rancher-desktop/components/ActionDropdown.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/ActionMenu.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/Alert.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/AsyncButton.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/BackendProgress.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/ContainerLogs.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/ContainerShell.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/ContainerStatusBadge.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/DashboardOpen.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/DiagnosticsBody.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/DiagnosticsButtonRun.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/EmptyState.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/EngineSelector.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/ExtensionsError.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/ExtensionsUninstalled.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/Help.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/ImageAddTabs.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/Images.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/ImagesButtonAdd.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/ImagesFormAdd.vue ================================================ ================================================ FILE: pkg/rancher-desktop/components/ImagesOutputWindow.vue ================================================